diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb5ed5b5bf..699ab070c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,12 @@ jobs: CODEX_VERSION=0.125.0 WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298" OUTPUT_DIR="${RUNNER_TEMP}" + # This reused workflow predates the standalone bwrap artifact. python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ --workflow-url "$WORKFLOW_URL" \ --package codex \ + --allow-missing-native-component bwrap \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index faab87f97b..71aab45394 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -255,6 +255,24 @@ jobs: with: target: ${{ matrix.target }} + - if: ${{ contains(matrix.target, 'linux') }} + name: Build bwrap and export digest + shell: bash + run: | + set -euo pipefail + target="${{ matrix.target }}" + cargo build --target "$target" --release --timings --bin bwrap + + bwrap_path="target/${target}/release/bwrap" + if [[ ! -f "$bwrap_path" ]]; then + echo "bwrap binary ${bwrap_path} not found" + exit 1 + fi + + digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" + echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" + echo "Built bwrap ${bwrap_path} with sha256:${digest}" + - name: Cargo build shell: bash run: | @@ -278,7 +296,7 @@ jobs: with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - binaries: ${{ matrix.binaries }} + binaries: ${{ matrix.binaries }} bwrap - if: ${{ runner.os == 'macOS' }} name: MacOS code signing (binaries) @@ -361,6 +379,12 @@ jobs: fi done + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then + cp "target/${{ matrix.target }}/release/bwrap" "$dest/bwrap-${{ matrix.target }}" + cp "target/${{ matrix.target }}/release/bwrap.sigstore" \ + "$dest/bwrap-${{ matrix.target }}.sigstore" + fi + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index eda6c26152..c38fed68d9 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -69,8 +69,8 @@ PACKAGE_EXPANSIONS: dict[str, list[str]] = { PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { "codex": [], - "codex-linux-x64": ["codex", "rg"], - "codex-linux-arm64": ["codex", "rg"], + "codex-linux-x64": ["bwrap", "codex", "rg"], + "codex-linux-arm64": ["bwrap", "codex", "rg"], "codex-darwin-x64": ["codex", "rg"], "codex-darwin-arm64": ["codex", "rg"], "codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], @@ -87,6 +87,7 @@ PACKAGE_TARGET_FILTERS: dict[str, str] = { PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS) COMPONENT_DEST_DIR: dict[str, str] = { + "bwrap": "codex-resources", "codex": "codex", "codex-responses-api-proxy": "codex-responses-api-proxy", "codex-windows-sandbox-setup": "codex", @@ -137,6 +138,16 @@ def parse_args() -> argparse.Namespace: type=Path, help="Directory containing pre-installed native binaries to bundle (vendor root).", ) + parser.add_argument( + "--allow-missing-native-component", + dest="allow_missing_native_components", + action="append", + default=[], + help=( + "Native component that may be absent from --vendor-src. Intended for CI " + "compatibility with older artifact workflows; releases should not use this." + ), + ) return parser.parse_args() @@ -177,6 +188,7 @@ def main() -> int: staging_dir, native_components, target_filter={target_filter} if target_filter else None, + allow_missing_components=set(args.allow_missing_native_components), ) if release_version: @@ -365,12 +377,14 @@ def copy_native_binaries( staging_dir: Path, components: list[str], target_filter: set[str] | None = None, + allow_missing_components: set[str] | None = None, ) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") components_set = {component for component in components if component in COMPONENT_DEST_DIR} + allow_missing_components = allow_missing_components or set() if not components_set: return @@ -399,6 +413,8 @@ def copy_native_binaries( src_component_dir = target_dir / dest_dir_name if not src_component_dir.exists(): + if component in allow_missing_components: + continue raise RuntimeError( f"Missing native component '{component}' in vendor source: {src_component_dir}" ) diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 58fbd370fc..19d0af5bae 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install Codex native binaries (Rust CLI plus ripgrep helpers).""" +"""Install Codex native binaries (Rust CLI, bwrap, and ripgrep helpers).""" import argparse from contextlib import contextmanager @@ -42,8 +42,15 @@ class BinaryComponent: WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target) +LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target) BINARY_COMPONENTS = { + "bwrap": BinaryComponent( + artifact_prefix="bwrap", + dest_dir="codex-resources", + binary_basename="bwrap", + targets=LINUX_TARGETS, + ), "codex": BinaryComponent( artifact_prefix="codex", dest_dir="codex", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a80e2f58e9..d0e54095e0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2125,6 +2125,15 @@ dependencies = [ "serde_with", ] +[[package]] +name = "codex-bwrap" +version = "0.0.0" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "codex-chatgpt" version = "0.0.0" @@ -2902,7 +2911,6 @@ dependencies = [ name = "codex-linux-sandbox" version = "0.0.0" dependencies = [ - "cc", "clap", "codex-core", "codex-process-hardening", @@ -2912,11 +2920,11 @@ dependencies = [ "globset", "landlock", "libc", - "pkg-config", "pretty_assertions", "seccompiler", "serde", "serde_json", + "sha2", "tempfile", "tokio", "url", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ce3e91626d..4cf0cbccaa 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -5,6 +5,7 @@ members = [ "agent-graph-store", "agent-identity", "backend-client", + "bwrap", "ansi-escape", "async-utils", "app-server", @@ -137,6 +138,7 @@ codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } +codex-bwrap = { path = "bwrap" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } codex-client = { path = "codex-client" } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 973617a94e..8d26cec70f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -954,12 +954,22 @@ impl MessageProcessor { } ClientRequest::ThreadResume { params, .. } => { self.thread_processor - .thread_resume(request_id.clone(), params) + .thread_resume( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) .await } ClientRequest::ThreadFork { params, .. } => { self.thread_processor - .thread_fork(request_id.clone(), params) + .thread_fork( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) .await } ClientRequest::ThreadArchive { params, .. } => { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index e4f9978cd9..ecc66465ba 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -421,20 +421,34 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result, JSONRPCErrorError> { - self.thread_resume_inner(request_id, params) - .await - .map(|()| None) + self.thread_resume_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|()| None) } pub(crate) async fn thread_fork( &self, request_id: ConnectionRequestId, params: ThreadForkParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result, JSONRPCErrorError> { - self.thread_fork_inner(request_id, params) - .await - .map(|()| None) + self.thread_fork_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|()| None) } pub(crate) async fn thread_archive( @@ -682,8 +696,16 @@ impl ThreadRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { + let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( + app_server_client_name.as_deref(), + app_server_client_version.as_deref(), + ); thread - .set_app_server_client_info(app_server_client_name, app_server_client_version) + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) .await .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) } @@ -2304,6 +2326,8 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2353,7 +2377,15 @@ impl ThreadRequestProcessor { return Ok(()); } }; - match self.resume_running_thread(&request_id, ¶ms).await { + match self + .resume_running_thread( + &request_id, + ¶ms, + app_server_client_name.clone(), + app_server_client_version.clone(), + ) + .await + { Ok(true) => return Ok(()), Ok(false) => {} Err(error) => { @@ -2505,6 +2537,16 @@ impl ThreadRequestProcessor { session_configured, .. }) => { + if let Err(err) = Self::set_app_server_client_info( + codex_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await + { + self.outgoing.send_error(request_id, err).await; + return Ok(()); + } let SessionConfiguredEvent { rollout_path, .. } = session_configured; let Some(rollout_path) = rollout_path else { let error = @@ -2643,6 +2685,8 @@ impl ThreadRequestProcessor { &self, request_id: &ConnectionRequestId, params: &ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result { let running_thread = if params.history.is_some() { if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) @@ -2724,6 +2768,12 @@ impl ThreadRequestProcessor { thread_state.clone(), ) .await?; + Self::set_app_server_client_info( + existing_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; let config_snapshot = existing_thread.config_snapshot().await; let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); @@ -3007,6 +3057,8 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadForkParams, + app_server_client_name: Option, + app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3167,6 +3219,13 @@ impl ThreadRequestProcessor { err => internal_error(format!("error forking thread: {err}")), })?; + Self::set_app_server_client_info( + forked_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + // Auto-attach a conversation listener when forking a thread. log_listener_attach_result( self.ensure_conversation_listener( @@ -3425,6 +3484,17 @@ impl ThreadRequestProcessor { } } +fn xcode_26_4_mcp_elicitations_auto_deny( + client_name: Option<&str>, + client_version: Option<&str>, +) -> bool { + // Xcode 26.4 shipped before app-server MCP elicitation requests were + // client-visible. Keep elicitations auto-denied for that client line. + // TODO: Remove this compatibility hack once Xcode 26.4 ages out. + client_name == Some("Xcode") + && client_version.is_some_and(|version| version.starts_with("26.4")) +} + const THREAD_TURNS_DEFAULT_LIMIT: usize = 25; const THREAD_TURNS_MAX_LIMIT: usize = 100; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 94172e4a94..98363dbfc9 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -553,8 +553,16 @@ impl TurnRequestProcessor { app_server_client_name: Option, app_server_client_version: Option, ) -> Result<(), JSONRPCErrorError> { + let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( + app_server_client_name.as_deref(), + app_server_client_version.as_deref(), + ); thread - .set_app_server_client_info(app_server_client_name, app_server_client_version) + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) .await .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) } @@ -1104,3 +1112,14 @@ impl TurnRequestProcessor { .await } } + +fn xcode_26_4_mcp_elicitations_auto_deny( + client_name: Option<&str>, + client_version: Option<&str>, +) -> bool { + // Xcode 26.4 shipped before app-server MCP elicitation requests were + // client-visible. Keep elicitations auto-denied for that client line. + // TODO: Remove this compatibility hack once Xcode 26.4 ages out. + client_name == Some("Xcode") + && client_version.is_some_and(|version| version.starts_with("26.4")) +} diff --git a/codex-rs/bwrap/BUILD.bazel b/codex-rs/bwrap/BUILD.bazel new file mode 100644 index 0000000000..3d0b89b966 --- /dev/null +++ b/codex-rs/bwrap/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "bwrap", + crate_name = "codex_bwrap", + # Bazel wires vendored bubblewrap + libcap via :bwrap-ffi below and sets + # bwrap_available explicitly, so we skip Cargo's build.rs in Bazel builds. + build_script_enabled = False, + deps_extra = select({ + "@platforms//os:linux": [":bwrap-ffi"], + "//conditions:default": [], + }), + rustc_flags_extra = select({ + "@platforms//os:linux": ["--cfg=bwrap_available"], + "//conditions:default": [], + }), +) + +cc_library( + name = "bwrap-ffi", + srcs = ["//codex-rs/vendor:bubblewrap_c_sources"], + hdrs = [ + "config.h", + "//codex-rs/vendor:bubblewrap_headers", + ], + copts = [ + "-D_GNU_SOURCE", + "-Dmain=bwrap_main", + ], + includes = ["."], + deps = ["@libcap//:libcap"], + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:private"], +) diff --git a/codex-rs/bwrap/Cargo.toml b/codex-rs/bwrap/Cargo.toml new file mode 100644 index 0000000000..ed7010c8fd --- /dev/null +++ b/codex-rs/bwrap/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-bwrap" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "bwrap" +path = "src/main.rs" + +[lints] +workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[build-dependencies] +cc = "1" +pkg-config = "0.3" diff --git a/codex-rs/bwrap/build.rs b/codex-rs/bwrap/build.rs new file mode 100644 index 0000000000..40c2712839 --- /dev/null +++ b/codex-rs/bwrap/build.rs @@ -0,0 +1,99 @@ +use std::env; +use std::path::Path; +use std::path::PathBuf; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(bwrap_available)"); + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR"); + println!("cargo:rerun-if-env-changed=CODEX_SKIP_BWRAP_BUILD"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + for source in ["bubblewrap.c", "bind-mount.c", "network.c", "utils.c"] { + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join(source).display() + ); + } + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "linux" || env::var_os("CODEX_SKIP_BWRAP_BUILD").is_some() { + return; + } + + if let Err(err) = try_build_bwrap() { + panic!("failed to compile bubblewrap for Linux target: {err}"); + } +} + +fn try_build_bwrap() -> Result<(), String> { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); + let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); + let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; + let libcap = pkg_config::Config::new() + .probe("libcap") + .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; + + let config_h = out_dir.join("config.h"); + std::fs::write( + &config_h, + r#"#pragma once +#define PACKAGE_STRING "bubblewrap built for Codex" +"#, + ) + .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; + + let mut build = cc::Build::new(); + build + .file(src_dir.join("bubblewrap.c")) + .file(src_dir.join("bind-mount.c")) + .file(src_dir.join("network.c")) + .file(src_dir.join("utils.c")) + .include(&out_dir) + .include(&src_dir) + .define("_GNU_SOURCE", None) + // Rename `main` so the Rust wrapper can expose the Cargo-built binary. + .define("main", Some("bwrap_main")); + for include_path in libcap.include_paths { + // Use -idirafter so target sysroot headers win (musl cross builds), + // while still allowing libcap headers from the host toolchain. + build.flag(format!("-idirafter{}", include_path.display())); + } + + build.compile("standalone_bwrap"); + println!("cargo:rustc-cfg=bwrap_available"); + Ok(()) +} + +/// Resolve the bubblewrap source directory used for build-time compilation. +/// +/// Priority: +/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. +/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. +fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { + if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { + let src_dir = PathBuf::from(path); + if src_dir.exists() { + return Ok(src_dir); + } + return Err(format!( + "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", + src_dir.display() + )); + } + + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + if vendor_dir.exists() { + return Ok(vendor_dir); + } + + Err(format!( + "expected vendored bubblewrap at {}, but it was not found.\n\ +Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", + vendor_dir.display() + )) +} diff --git a/codex-rs/bwrap/config.h b/codex-rs/bwrap/config.h new file mode 100644 index 0000000000..f73932a0f8 --- /dev/null +++ b/codex-rs/bwrap/config.h @@ -0,0 +1 @@ +#define PACKAGE_STRING "bubblewrap built for Codex" diff --git a/codex-rs/bwrap/src/main.rs b/codex-rs/bwrap/src/main.rs new file mode 100644 index 0000000000..68b53d50f6 --- /dev/null +++ b/codex-rs/bwrap/src/main.rs @@ -0,0 +1,43 @@ +#[cfg(all(target_os = "linux", bwrap_available))] +fn main() { + use std::ffi::CString; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + + unsafe extern "C" { + fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; + } + + let cstrings = std::env::args_os() + .map(|arg| { + CString::new(arg.as_os_str().as_bytes()) + .unwrap_or_else(|err| panic!("failed to convert argv to CString: {err}")) + }) + .collect::>(); + let mut argv_ptrs = cstrings + .iter() + .map(CString::as_ptr) + .collect::>(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: We provide a null-terminated argv vector whose pointers remain + // valid for the duration of the call. + let exit_code = unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) }; + std::process::exit(exit_code); +} + +#[cfg(all(target_os = "linux", not(bwrap_available)))] +fn main() { + panic!( + r#"bubblewrap is not available in this build. +Notes: +- ensure the target OS is Linux +- libcap headers must be available via pkg-config +- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"# + ); +} + +#[cfg(not(target_os = "linux"))] +fn main() { + panic!("bwrap is only supported on Linux"); +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 483a82796a..53992b927b 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -128,6 +128,14 @@ impl McpConnectionManager { } } + pub fn elicitations_auto_deny(&self) -> bool { + self.elicitation_requests.auto_deny() + } + + pub fn set_elicitations_auto_deny(&self, auto_deny: bool) { + self.elicitation_requests.set_auto_deny(auto_deny); + } + #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] pub async fn new( mcp_servers: &HashMap, diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index def12a9d63..43c9deb246 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -36,6 +36,7 @@ pub(crate) struct ElicitationRequestManager { requests: Arc>, pub(crate) approval_policy: Arc>, pub(crate) permission_profile: Arc>, + auto_deny: Arc>, } impl ElicitationRequestManager { @@ -47,6 +48,20 @@ impl ElicitationRequestManager { requests: Arc::new(Mutex::new(HashMap::new())), approval_policy: Arc::new(StdMutex::new(approval_policy)), permission_profile: Arc::new(StdMutex::new(permission_profile)), + auto_deny: Arc::new(StdMutex::new(false)), + } + } + + pub(crate) fn auto_deny(&self) -> bool { + self.auto_deny + .lock() + .map(|auto_deny| *auto_deny) + .unwrap_or(false) + } + + pub(crate) fn set_auto_deny(&self, auto_deny: bool) { + if let Ok(mut current) = self.auto_deny.lock() { + *current = auto_deny; } } @@ -73,13 +88,27 @@ impl ElicitationRequestManager { let elicitation_requests = self.requests.clone(); let approval_policy = self.approval_policy.clone(); let permission_profile = self.permission_profile.clone(); + let auto_deny = self.auto_deny.clone(); Box::new(move |id, elicitation| { let elicitation_requests = elicitation_requests.clone(); let tx_event = tx_event.clone(); let server_name = server_name.clone(); let approval_policy = approval_policy.clone(); let permission_profile = permission_profile.clone(); + let auto_deny = auto_deny.clone(); async move { + let auto_deny = auto_deny + .lock() + .map(|auto_deny| *auto_deny) + .unwrap_or(false); + if auto_deny { + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + } + let approval_policy = approval_policy .lock() .map(|policy| *policy) diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index f6f63191b5..fab78a128c 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -7,6 +7,7 @@ use crate::config_toml::ToolsToml; use crate::types::AnalyticsConfigToml; use crate::types::ApprovalsReviewer; use crate::types::Personality; +use crate::types::SessionPickerViewMode; use crate::types::WindowsToml; use codex_features::FeaturesToml; use codex_protocol::config_types::ReasoningSummary; @@ -63,6 +64,9 @@ pub struct ConfigProfile { pub tools: Option, pub web_search: Option, pub analytics: Option, + /// TUI settings scoped to this profile. + #[serde(default)] + pub tui: Option, #[serde(default)] pub windows: Option, /// Optional feature toggles scoped to this profile. @@ -73,6 +77,16 @@ pub struct ConfigProfile { pub oss_provider: Option, } +/// TUI settings supported inside a named profile. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct ProfileTui { + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, +} + impl From for codex_app_server_protocol::Profile { fn from(config_profile: ConfigProfile) -> Self { Self { diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 62ba242148..6cb9abc507 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -57,6 +57,30 @@ const fn default_enabled() -> bool { true } +/// Preferred layout for the resume/fork session picker. +#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum SessionPickerViewMode { + Comfortable, + #[default] + Dense, +} + +impl SessionPickerViewMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Comfortable => "comfortable", + Self::Dense => "dense", + } + } +} + +impl fmt::Display for SessionPickerViewMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + /// Determine where Codex should store CLI auth credentials. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] @@ -661,6 +685,10 @@ pub struct Tui { #[serde(default)] pub theme: Option, + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, + /// Keybinding overrides for the TUI. /// /// This supports rebinding selected actions globally and by context. diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index aa68656d1b..e884cfae9b 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -18,6 +18,7 @@ pub use codex_config::types::ModelAvailabilityNuxConfig; pub use codex_config::types::Notice; pub use codex_config::types::OAuthCredentialsStoreMode; pub use codex_config::types::OtelConfig; +pub use codex_config::types::SessionPickerViewMode; pub use codex_config::types::ToolSuggestConfig; pub use codex_config::types::TuiKeymap; pub use codex_config::types::TuiNotificationSettings; diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index be222a1673..3283ba2c3e 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -39,14 +39,14 @@ The Linux sandbox helper prefers the first `bwrap` found on `PATH` outside the current working directory whenever it is available. If `bwrap` is present but too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a no-`--argv0` compatibility path for the inner re-exec. If -`bwrap` is missing, it falls back to the vendored bubblewrap path compiled into -the binary and Codex surfaces a startup warning through its normal notification -path instead of printing directly from the sandbox helper. Codex also surfaces -a startup warning when bubblewrap cannot create user namespaces. WSL2 uses the -normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing -because it cannot create the required user namespaces, so Codex rejects -sandboxed shell commands that would enter the bubblewrap path before invoking -`bwrap`. +`bwrap` is missing, it falls back to the bundled `codex-resources/bwrap` +binary shipped with Codex and Codex surfaces a startup warning through its +normal notification path instead of printing directly from the sandbox helper. +Codex also surfaces a startup warning when bubblewrap cannot create user +namespaces. WSL2 uses the normal Linux bubblewrap path. WSL1 is not supported +for bubblewrap sandboxing because it cannot create the required user +namespaces, so Codex rejects sandboxed shell commands that would enter the +bubblewrap path before invoking `bwrap`. ### Windows diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index fc8d3b2745..c40b7654ab 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -674,6 +674,15 @@ "tools_view_image": { "type": "boolean" }, + "tui": { + "allOf": [ + { + "$ref": "#/definitions/ProfileTui" + } + ], + "default": null, + "description": "TUI settings scoped to this profile." + }, "web_search": { "$ref": "#/definitions/WebSearchMode" }, @@ -1876,6 +1885,22 @@ }, "type": "object" }, + "ProfileTui": { + "additionalProperties": false, + "description": "TUI settings supported inside a named profile.", + "properties": { + "session_picker_view": { + "allOf": [ + { + "$ref": "#/definitions/SessionPickerViewMode" + } + ], + "default": null, + "description": "Preferred layout for resume/fork session picker results." + } + }, + "type": "object" + }, "ProjectConfig": { "additionalProperties": false, "properties": { @@ -2163,6 +2188,14 @@ ], "type": "string" }, + "SessionPickerViewMode": { + "description": "Preferred layout for the resume/fork session picker.", + "enum": [ + "comfortable", + "dense" + ], + "type": "string" + }, "ShellEnvironmentPolicyInherit": { "oneOf": [ { @@ -2567,6 +2600,15 @@ "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", "type": "boolean" }, + "session_picker_view": { + "allOf": [ + { + "$ref": "#/definitions/SessionPickerViewMode" + } + ], + "default": null, + "description": "Preferred layout for resume/fork session picker results." + }, "show_tooltips": { "default": true, "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 508935166c..8a883ce930 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -227,9 +227,14 @@ impl CodexThread { &self, app_server_client_name: Option, app_server_client_version: Option, + mcp_elicitations_auto_deny: bool, ) -> ConstraintResult<()> { self.codex - .set_app_server_client_info(app_server_client_name, app_server_client_version) + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) .await } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 4e00470b33..3bf5ad1a69 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -44,6 +44,7 @@ use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; use codex_config::types::SandboxWorkspaceWrite; +use codex_config::types::SessionPickerViewMode; use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; @@ -563,6 +564,7 @@ fn config_toml_deserializes_model_availability_nux() { status_line_use_colors: true, terminal_title: None, theme: None, + session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig { shown_count: HashMap::from([ @@ -2156,6 +2158,31 @@ fn tui_theme_defaults_to_none() { assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None); } +#[test] +fn tui_session_picker_view_deserializes_from_toml() { + let cfg = r#" +[tui] +session_picker_view = "dense" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.session_picker_view), + Some(SessionPickerViewMode::Dense), + ); +} + +#[test] +fn tui_session_picker_view_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.session_picker_view), + None, + ); +} + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2179,6 +2206,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { status_line_use_colors: true, terminal_title: None, theme: None, + session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow_max_rows: None, @@ -2244,6 +2272,78 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() ); } +#[test] +fn profile_tui_rejects_unsupported_settings() { + let err = toml::from_str::( + r#"profile = "work" + +[profiles.work.tui] +theme = "dark" +"#, + ) + .expect_err("profile TUI config should only accept supported fields"); + + assert!(err.to_string().contains("unknown field")); + assert!(err.to_string().contains("theme")); +} + +#[tokio::test] +async fn runtime_config_resolves_session_picker_view_default_and_override() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load default config"); + + assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense); + + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + session_picker_view: Some(SessionPickerViewMode::Comfortable), + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load root override config"); + + assert_eq!( + cfg.tui_session_picker_view, + SessionPickerViewMode::Comfortable + ); + + let cfg_toml = toml::from_str::( + r#"profile = "work" + +[tui] +session_picker_view = "dense" + +[profiles.work.tui] +session_picker_view = "comfortable" +"#, + ) + .expect("parse profile scoped tui config"); + + let cfg = Config::load_from_base_config_with_overrides( + cfg_toml, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load profile override config"); + + assert_eq!( + cfg.tui_session_picker_view, + SessionPickerViewMode::Comfortable + ); +} + #[tokio::test] async fn test_sandbox_config_parsing() { let sandbox_full_access = r#" @@ -6514,6 +6614,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }, o3_profile_config @@ -6718,6 +6819,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -6876,6 +6978,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -7019,6 +7122,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 8d4128900d..1362103364 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -3,6 +3,7 @@ use crate::path_utils::write_atomically; use anyhow::Context; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; +use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestDisabledTool; use codex_features::FEATURES; use codex_protocol::config_types::Personality; @@ -91,6 +92,14 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit { } } +/// Produces a config edit that sets `[tui].session_picker_view = ""`. +pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "session_picker_view".to_string()], + value: value(mode.to_string()), + } +} + /// Produces a config edit that sets `[tui].status_line` to an explicit ordered list. /// /// The array is written even when it is empty so "hide the status line" stays @@ -1316,6 +1325,25 @@ impl ConfigEditsBuilder { self } + pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self { + let segments = if let Some(profile) = self.profile.as_ref() { + vec![ + "profiles".to_string(), + profile.clone(), + "tui".to_string(), + "session_picker_view".to_string(), + ] + } else { + vec!["tui".to_string(), "session_picker_view".to_string()] + }; + + self.edits.push(ConfigEdit::SetPath { + segments, + value: value(mode.to_string()), + }); + self + } + pub fn with_edits(mut self, edits: I) -> Self where I: IntoIterator, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 376632a93a..45af723b8f 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -2,6 +2,7 @@ use super::*; use codex_config::types::AppToolApproval; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_config::types::SessionPickerViewMode; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; #[cfg(unix)] @@ -48,6 +49,41 @@ fn builder_with_edits_applies_custom_paths() { assert_eq!(contents, "enabled = true\n"); } +#[test] +fn session_picker_view_edit_writes_root_tui_setting() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([session_picker_view_edit(SessionPickerViewMode::Dense)]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[tui] +session_picker_view = "dense" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn session_picker_view_builder_respects_active_profile() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_profile(Some("work")) + .set_session_picker_view(SessionPickerViewMode::Dense) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[profiles.work.tui] +session_picker_view = "dense" +"#; + assert_eq!(contents, expected); +} + #[test] fn keymap_binding_edit_writes_root_action_binding() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 81631047c2..15a5dfe15e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -49,6 +49,7 @@ use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; +use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; @@ -546,6 +547,9 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Preferred layout for resume/fork session picker results. + pub tui_session_picker_view: SessionPickerViewMode, + /// Terminal resize-reflow tuning knobs. pub terminal_resize_reflow: TerminalResizeReflowConfig, @@ -3162,6 +3166,12 @@ impl Config { .unwrap_or(true), tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + tui_session_picker_view: config_profile + .tui + .as_ref() + .and_then(|t| t.session_picker_view) + .or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view)) + .unwrap_or_default(), terminal_resize_reflow, tui_keymap: cfg .tui diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 5cdc1daa65..59bb4f189a 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -254,6 +254,10 @@ impl Session { auth.as_ref(), ) .await; + { + let current_manager = self.services.mcp_connection_manager.read().await; + refreshed_manager.set_elicitations_auto_deny(current_manager.elicitations_auto_deny()); + } { let mut guard = self.services.mcp_startup_cancellation_token.lock().await; if guard.is_cancelled() { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5022b54310..f87e94145f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -759,6 +759,7 @@ impl Codex { &self, app_server_client_name: Option, app_server_client_version: Option, + mcp_elicitations_auto_deny: bool, ) -> ConstraintResult<()> { self.session .update_settings(SessionSettingsUpdate { @@ -766,7 +767,10 @@ impl Codex { app_server_client_version, ..Default::default() }) - .await + .await?; + let mcp_connection_manager = self.session.services.mcp_connection_manager.read().await; + mcp_connection_manager.set_elicitations_auto_deny(mcp_elicitations_auto_deny); + Ok(()) } pub(crate) async fn agent_status(&self) -> AgentStatus { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3d0cc4b208..38eda8f7dd 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -66,9 +66,10 @@ use crate::tasks::execute_user_shell_command; use crate::tools::ToolRouter; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::handlers::GoalHandler; +use crate::tools::handlers::CreateGoalHandler; +use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ShellHandler; -use crate::tools::handlers::UnifiedExecHandler; +use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolHandler; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; @@ -8269,7 +8270,7 @@ async fn sample_rollout( async fn create_goal_tool_rejects_existing_goal() { let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = GoalHandler; + let handler = CreateGoalHandler; handler .handle(ToolInvocation { @@ -8331,9 +8332,10 @@ async fn create_goal_tool_rejects_existing_goal() { async fn update_goal_tool_rejects_pausing_goal() { let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = GoalHandler; + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; - handler + create_handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), @@ -8353,7 +8355,7 @@ async fn update_goal_tool_rejects_pausing_goal() { .await .expect("initial create_goal should succeed"); - let response = handler + let response = update_handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), @@ -8391,9 +8393,10 @@ async fn update_goal_tool_rejects_pausing_goal() { async fn update_goal_tool_marks_goal_complete() { let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = GoalHandler; + let create_handler = CreateGoalHandler; + let update_handler = UpdateGoalHandler; - handler + create_handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), @@ -8413,7 +8416,7 @@ async fn update_goal_tool_marks_goal_complete() { .await .expect("initial create_goal should succeed"); - handler + update_handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), @@ -8570,7 +8573,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() let turn_context = Arc::new(turn_context_raw); let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = UnifiedExecHandler; + let handler = ExecCommandHandler; let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index ad7dbb1054..4c61dad18d 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -498,7 +498,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic let turn_context = Arc::new(turn_context_raw); let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = UnifiedExecHandler; + let handler = ExecCommandHandler; let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 6b99e09b56..42841d2189 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -4,6 +4,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; use super::ExecContext; use super::PUBLIC_TOOL_NAME; @@ -78,6 +79,10 @@ impl CodeModeExecuteHandler { impl ToolHandler for CodeModeExecuteHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(PUBLIC_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 70fa51251a..8024c95865 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -6,6 +6,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; use super::DEFAULT_WAIT_YIELD_TIME_MS; use super::ExecContext; @@ -41,6 +42,10 @@ where impl ToolHandler for CodeModeWaitHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(WAIT_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index b3d9b481de..93380d5e4b 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; +use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; use futures::StreamExt; use futures::stream::FuturesUnordered; @@ -35,7 +36,8 @@ use tokio::time::Instant; use tokio::time::timeout; use uuid::Uuid; -pub struct BatchJobHandler; +pub struct SpawnAgentsOnCsvHandler; +pub struct ReportAgentJobResultHandler; const DEFAULT_AGENT_JOB_CONCURRENCY: usize = 16; const MAX_AGENT_JOB_CONCURRENCY: usize = 64; @@ -99,9 +101,13 @@ struct ActiveJobItem { status_rx: Option>, } -impl ToolHandler for BatchJobHandler { +impl ToolHandler for SpawnAgentsOnCsvHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("spawn_agents_on_csv") + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -114,7 +120,6 @@ impl ToolHandler for BatchJobHandler { let ToolInvocation { session, turn, - tool_name, payload, .. } = invocation; @@ -128,13 +133,40 @@ impl ToolHandler for BatchJobHandler { } }; - match tool_name.name.as_str() { - "spawn_agents_on_csv" => spawn_agents_on_csv::handle(session, turn, arguments).await, - "report_agent_job_result" => report_agent_job_result::handle(session, arguments).await, - other => Err(FunctionCallError::RespondToModel(format!( - "unsupported agent job tool {other}" - ))), - } + spawn_agents_on_csv::handle(session, turn, arguments).await + } +} + +impl ToolHandler for ReportAgentJobResultHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("report_agent_job_result") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, payload, .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "report_agent_job_result handler received unsupported payload".to_string(), + )); + } + }; + + report_agent_job_result::handle(session, arguments).await } } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index e2e020a96a..9766bfb573 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -47,6 +47,7 @@ use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy; use codex_sandboxing::policy_transforms::merge_permission_profiles; use codex_sandboxing::policy_transforms::normalize_additional_permissions; use codex_tools::ApplyPatchToolArgs; +use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500); @@ -292,6 +293,10 @@ async fn effective_patch_permissions( impl ToolHandler for ApplyPatchHandler { type Output = ApplyPatchToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("apply_patch") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index eab1f0f808..549edd5148 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -19,11 +19,23 @@ use std::time::Instant; use tokio::sync::oneshot; use tracing::warn; -pub struct DynamicToolHandler; +pub struct DynamicToolHandler { + tool_name: ToolName, +} + +impl DynamicToolHandler { + pub fn new(tool_name: ToolName) -> Self { + Self { tool_name } + } +} impl ToolHandler for DynamicToolHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -37,7 +49,6 @@ impl ToolHandler for DynamicToolHandler { session, turn, call_id, - tool_name, payload, .. } = invocation; @@ -52,13 +63,19 @@ impl ToolHandler for DynamicToolHandler { }; let args: Value = parse_arguments(&arguments)?; - let response = request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name, args) - .await - .ok_or_else(|| { - FunctionCallError::RespondToModel( - "dynamic tool call was cancelled before receiving a response".to_string(), - ) - })?; + let response = request_dynamic_tool( + &session, + turn.as_ref(), + call_id, + self.tool_name.clone(), + args, + ) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "dynamic tool call was cancelled before receiving a response".to_string(), + ) + })?; let DynamicToolResponse { content_items, diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs index 74391d57bf..6a7b304ce4 100644 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ b/codex-rs/core/src/tools/handlers/goal.rs @@ -8,8 +8,6 @@ use crate::function_tool::FunctionCallError; use crate::goals::CreateGoalRequest; use crate::goals::GoalRuntimeEvent; use crate::goals::SetGoalRequest; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -20,13 +18,15 @@ use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_tools::CREATE_GOAL_TOOL_NAME; use codex_tools::GET_GOAL_TOOL_NAME; +use codex_tools::ToolName; use codex_tools::UPDATE_GOAL_TOOL_NAME; use serde::Deserialize; use serde::Serialize; use std::fmt::Write as _; -use std::sync::Arc; -pub struct GoalHandler; +pub struct GetGoalHandler; +pub struct CreateGoalHandler; +pub struct UpdateGoalHandler; #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case")] @@ -76,9 +76,44 @@ impl GoalToolResponse { } } -impl ToolHandler for GoalHandler { +impl ToolHandler for GetGoalHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(GET_GOAL_TOOL_NAME) + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, payload, .. + } = invocation; + + match payload { + ToolPayload::Function { .. } => { + let goal = session + .get_thread_goal() + .await + .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; + goal_response(goal, CompletionBudgetReport::Omit) + } + _ => Err(FunctionCallError::RespondToModel( + "get_goal handler received unsupported payload".to_string(), + )), + } + } +} + +impl ToolHandler for CreateGoalHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain(CREATE_GOAL_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -88,7 +123,6 @@ impl ToolHandler for GoalHandler { session, turn, payload, - tool_name, .. } = invocation; @@ -101,88 +135,89 @@ impl ToolHandler for GoalHandler { } }; - match tool_name.name.as_str() { - GET_GOAL_TOOL_NAME => handle_get_goal(session.as_ref()).await, - CREATE_GOAL_TOOL_NAME => { - handle_create_goal(session.as_ref(), turn.as_ref(), &arguments).await + let args: CreateGoalArgs = parse_arguments(&arguments)?; + let goal = session + .create_thread_goal( + turn.as_ref(), + CreateGoalRequest { + objective: args.objective, + token_budget: args.token_budget, + }, + ) + .await + .map_err(|err| { + if err + .chain() + .any(|cause| cause.to_string().contains("already has a goal")) + { + FunctionCallError::RespondToModel( + "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" + .to_string(), + ) + } else { + FunctionCallError::RespondToModel(format_goal_error(err)) + } + })?; + goal_response(Some(goal), CompletionBudgetReport::Omit) + } +} + +impl ToolHandler for UpdateGoalHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain(UPDATE_GOAL_TOOL_NAME) + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "update_goal handler received unsupported payload".to_string(), + )); } - UPDATE_GOAL_TOOL_NAME => handle_update_goal(&session, turn.as_ref(), &arguments).await, - other => Err(FunctionCallError::Fatal(format!( - "goal handler received unsupported tool: {other}" - ))), + }; + + let args: UpdateGoalArgs = parse_arguments(&arguments)?; + if args.status != ThreadGoalStatus::Complete { + return Err(FunctionCallError::RespondToModel( + "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + .to_string(), + )); } + session + .goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { + turn_context: turn.as_ref(), + }) + .await + .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; + let goal = session + .set_thread_goal( + turn.as_ref(), + SetGoalRequest { + objective: None, + status: Some(ThreadGoalStatus::Complete), + token_budget: None, + }, + ) + .await + .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; + goal_response(Some(goal), CompletionBudgetReport::Include) } } -async fn handle_get_goal(session: &Session) -> Result { - let goal = session - .get_thread_goal() - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(goal, CompletionBudgetReport::Omit) -} - -async fn handle_create_goal( - session: &Session, - turn_context: &TurnContext, - arguments: &str, -) -> Result { - let args: CreateGoalArgs = parse_arguments(arguments)?; - let goal = session - .create_thread_goal( - turn_context, - CreateGoalRequest { - objective: args.objective, - token_budget: args.token_budget, - }, - ) - .await - .map_err(|err| { - if err - .chain() - .any(|cause| cause.to_string().contains("already has a goal")) - { - FunctionCallError::RespondToModel( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" - .to_string(), - ) - } else { - FunctionCallError::RespondToModel(format_goal_error(err)) - } - })?; - goal_response(Some(goal), CompletionBudgetReport::Omit) -} - -async fn handle_update_goal( - session: &Arc, - turn_context: &TurnContext, - arguments: &str, -) -> Result { - let args: UpdateGoalArgs = parse_arguments(arguments)?; - if args.status != ThreadGoalStatus::Complete { - return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" - .to_string(), - )); - } - session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { turn_context }) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - let goal = session - .set_thread_goal( - turn_context, - SetGoalRequest { - objective: None, - status: Some(ThreadGoalStatus::Complete), - token_budget: None, - }, - ) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(Some(goal), CompletionBudgetReport::Include) -} - fn format_goal_error(err: anyhow::Error) -> String { let mut message = err.to_string(); for cause in err.chain().skip(1) { diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 568e456158..4dfcb44b1f 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -13,12 +13,26 @@ use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; use serde_json::Value; -pub struct McpHandler; +pub struct McpHandler { + tool_name: ToolName, +} + +impl McpHandler { + pub fn new(tool_name: ToolName) -> Self { + Self { tool_name } + } +} + impl ToolHandler for McpHandler { type Output = McpToolOutput; + fn tool_name(&self) -> ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Mcp } @@ -29,7 +43,7 @@ impl ToolHandler for McpHandler { }; Some(PreToolUsePayload { - tool_name: HookToolName::new(invocation.tool_name.display()), + tool_name: HookToolName::new(self.tool_name.display()), tool_input: mcp_hook_tool_input(raw_arguments), }) } @@ -46,7 +60,7 @@ impl ToolHandler for McpHandler { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { - tool_name: HookToolName::new(invocation.tool_name.display()), + tool_name: HookToolName::new(self.tool_name.display()), tool_use_id: invocation.call_id.clone(), tool_input: result.tool_input.clone(), tool_response, @@ -58,7 +72,6 @@ impl ToolHandler for McpHandler { session, turn, call_id, - tool_name: model_tool_name, payload, .. } = invocation; @@ -86,7 +99,7 @@ impl ToolHandler for McpHandler { call_id.clone(), server, tool, - model_tool_name.display(), + self.tool_name.display(), arguments_str, ) .await; @@ -134,9 +147,13 @@ mod tests { .to_string(), }; let (session, turn) = make_session_and_context().await; + let handler = McpHandler::new(codex_tools::ToolName::namespaced( + "mcp__memory__", + "create_entities", + )); assert_eq!( - McpHandler.pre_tool_use_payload(&ToolInvocation { + handler.pre_tool_use_payload(&ToolInvocation { session: session.into(), turn: turn.into(), cancellation_token: tokio_util::sync::CancellationToken::new(), @@ -185,6 +202,10 @@ mod tests { truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024), }; let (session, turn) = make_session_and_context().await; + let handler = McpHandler::new(codex_tools::ToolName::namespaced( + "mcp__filesystem__", + "read_file", + )); let invocation = ToolInvocation { session: session.into(), turn: turn.into(), @@ -196,7 +217,7 @@ mod tests { payload, }; assert_eq!( - McpHandler.post_tool_use_payload(&invocation, &output), + handler.post_tool_use_payload(&invocation, &output), Some(PostToolUsePayload { tool_name: HookToolName::new("mcp__filesystem__read_file"), tool_use_id: "call-mcp-post".to_string(), diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index 14f8db3a4c..b03de53ae1 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -30,8 +30,11 @@ use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use codex_protocol::protocol::McpInvocation; +use codex_tools::ToolName; -pub struct McpResourceHandler; +pub struct ListMcpResourcesHandler; +pub struct ListMcpResourceTemplatesHandler; +pub struct ReadMcpResourceHandler; #[derive(Debug, Deserialize, Default)] struct ListResourcesArgs { @@ -178,9 +181,281 @@ struct ReadResourcePayload { result: ReadResourceResult, } -impl ToolHandler for McpResourceHandler { +impl ToolHandler for ListMcpResourcesHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("list_mcp_resources") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "MCP resource listing reads through the session-owned manager guard" + )] + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "list_mcp_resources handler received unsupported payload".to_string(), + )); + } + }; + + let arguments = parse_arguments(arguments.as_str())?; + let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?; + let ListResourcesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resources".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor.clone().map(|value| PaginatedRequestParams { + meta: None, + cursor: Some(value), + }); + let result = session + .list_resources(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}")) + })?; + Ok(ListResourcesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let resources = session + .services + .mcp_connection_manager + .read() + .await + .list_all_resources() + .await; + Ok(ListResourcesPayload::from_all_servers(resources)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let content = function_call_output_content_items_to_text(&output.body) + .unwrap_or_default(); + let duration = start.elapsed(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(&content, output.success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } + } +} + +impl ToolHandler for ListMcpResourceTemplatesHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("list_mcp_resource_templates") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + #[expect( + clippy::await_holding_invalid_type, + reason = "MCP resource template listing reads through the session-owned manager guard" + )] + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "list_mcp_resource_templates handler received unsupported payload".to_string(), + )); + } + }; + + let arguments = parse_arguments(arguments.as_str())?; + let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?; + let ListResourceTemplatesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resource_templates".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor.clone().map(|value| PaginatedRequestParams { + meta: None, + cursor: Some(value), + }); + let result = session + .list_resource_templates(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "resources/templates/list failed: {err:#}" + )) + })?; + Ok(ListResourceTemplatesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let templates = session + .services + .mcp_connection_manager + .read() + .await + .list_all_resource_templates() + .await; + Ok(ListResourceTemplatesPayload::from_all_servers(templates)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let content = function_call_output_content_items_to_text(&output.body) + .unwrap_or_default(); + let duration = start.elapsed(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(&content, output.success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } + } +} + +impl ToolHandler for ReadMcpResourceHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("read_mcp_resource") + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -190,7 +465,6 @@ impl ToolHandler for McpResourceHandler { session, turn, call_id, - tool_name, payload, .. } = invocation; @@ -199,124 +473,80 @@ impl ToolHandler for McpResourceHandler { ToolPayload::Function { arguments } => arguments, _ => { return Err(FunctionCallError::RespondToModel( - "mcp_resource handler received unsupported payload".to_string(), + "read_mcp_resource handler received unsupported payload".to_string(), )); } }; - let arguments_value = parse_arguments(arguments.as_str())?; + let arguments = parse_arguments(arguments.as_str())?; + let args: ReadResourceArgs = parse_args(arguments.clone())?; + let ReadResourceArgs { server, uri } = args; + let server = normalize_required_string("server", server)?; + let uri = normalize_required_string("uri", uri)?; - match tool_name.name.as_str() { - "list_mcp_resources" => { - handle_list_resources( - Arc::clone(&session), - Arc::clone(&turn), - call_id.clone(), - arguments_value.clone(), - ) - .await - } - "list_mcp_resource_templates" => { - handle_list_resource_templates( - Arc::clone(&session), - Arc::clone(&turn), - call_id.clone(), - arguments_value.clone(), - ) - .await - } - "read_mcp_resource" => { - handle_read_resource( - Arc::clone(&session), - Arc::clone(&turn), - call_id, - arguments_value, - ) - .await - } - other => Err(FunctionCallError::RespondToModel(format!( - "unsupported MCP resource tool: {other}" - ))), - } - } -} + let invocation = McpInvocation { + server: server.clone(), + tool: "read_mcp_resource".to_string(), + arguments: arguments.clone(), + }; -#[expect( - clippy::await_holding_invalid_type, - reason = "MCP resource listing reads through the session-owned manager guard" -)] -async fn handle_list_resources( - session: Arc, - turn: Arc, - call_id: String, - arguments: Option, -) -> Result { - let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?; - let ListResourcesArgs { server, cursor } = args; - let server = normalize_optional_string(server); - let cursor = normalize_optional_string(cursor); + emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; + let start = Instant::now(); - let invocation = McpInvocation { - server: server.clone().unwrap_or_else(|| "codex".to_string()), - tool: "list_mcp_resources".to_string(), - arguments: arguments.clone(), - }; - - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; - let start = Instant::now(); - - let payload_result: Result = async { - if let Some(server_name) = server.clone() { - let params = cursor.clone().map(|value| PaginatedRequestParams { - meta: None, - cursor: Some(value), - }); + let payload_result: Result = async { let result = session - .list_resources(&server_name, params) + .read_resource( + &server, + ReadResourceRequestParams { + meta: None, + uri: uri.clone(), + }, + ) .await .map_err(|err| { - FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}")) + FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) })?; - Ok(ListResourcesPayload::from_single_server( - server_name, + + Ok(ReadResourcePayload { + server, + uri, result, - )) - } else { - if cursor.is_some() { - return Err(FunctionCallError::RespondToModel( - "cursor can only be used when a server is specified".to_string(), - )); - } - - let resources = session - .services - .mcp_connection_manager - .read() - .await - .list_all_resources() - .await; - Ok(ListResourcesPayload::from_all_servers(resources)) + }) } - } - .await; + .await; - match payload_result { - Ok(payload) => match serialize_function_output(payload) { - Ok(output) => { - let content = - function_call_output_content_items_to_text(&output.body).unwrap_or_default(); - let duration = start.elapsed(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Ok(call_tool_result_from_content(&content, output.success)), - ) - .await; - Ok(output) - } + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let content = function_call_output_content_items_to_text(&output.body) + .unwrap_or_default(); + let duration = start.elapsed(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(&content, output.success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + turn.as_ref(), + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, Err(err) => { let duration = start.elapsed(); let message = err.to_string(); @@ -331,221 +561,6 @@ async fn handle_list_resources( .await; Err(err) } - }, - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - } -} - -#[expect( - clippy::await_holding_invalid_type, - reason = "MCP resource template listing reads through the session-owned manager guard" -)] -async fn handle_list_resource_templates( - session: Arc, - turn: Arc, - call_id: String, - arguments: Option, -) -> Result { - let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?; - let ListResourceTemplatesArgs { server, cursor } = args; - let server = normalize_optional_string(server); - let cursor = normalize_optional_string(cursor); - - let invocation = McpInvocation { - server: server.clone().unwrap_or_else(|| "codex".to_string()), - tool: "list_mcp_resource_templates".to_string(), - arguments: arguments.clone(), - }; - - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; - let start = Instant::now(); - - let payload_result: Result = async { - if let Some(server_name) = server.clone() { - let params = cursor.clone().map(|value| PaginatedRequestParams { - meta: None, - cursor: Some(value), - }); - let result = session - .list_resource_templates(&server_name, params) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "resources/templates/list failed: {err:#}" - )) - })?; - Ok(ListResourceTemplatesPayload::from_single_server( - server_name, - result, - )) - } else { - if cursor.is_some() { - return Err(FunctionCallError::RespondToModel( - "cursor can only be used when a server is specified".to_string(), - )); - } - - let templates = session - .services - .mcp_connection_manager - .read() - .await - .list_all_resource_templates() - .await; - Ok(ListResourceTemplatesPayload::from_all_servers(templates)) - } - } - .await; - - match payload_result { - Ok(payload) => match serialize_function_output(payload) { - Ok(output) => { - let content = - function_call_output_content_items_to_text(&output.body).unwrap_or_default(); - let duration = start.elapsed(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Ok(call_tool_result_from_content(&content, output.success)), - ) - .await; - Ok(output) - } - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - }, - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - } -} - -async fn handle_read_resource( - session: Arc, - turn: Arc, - call_id: String, - arguments: Option, -) -> Result { - let args: ReadResourceArgs = parse_args(arguments.clone())?; - let ReadResourceArgs { server, uri } = args; - let server = normalize_required_string("server", server)?; - let uri = normalize_required_string("uri", uri)?; - - let invocation = McpInvocation { - server: server.clone(), - tool: "read_mcp_resource".to_string(), - arguments: arguments.clone(), - }; - - emit_tool_call_begin(&session, turn.as_ref(), &call_id, invocation.clone()).await; - let start = Instant::now(); - - let payload_result: Result = async { - let result = session - .read_resource( - &server, - ReadResourceRequestParams { - meta: None, - uri: uri.clone(), - }, - ) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) - })?; - - Ok(ReadResourcePayload { - server, - uri, - result, - }) - } - .await; - - match payload_result { - Ok(payload) => match serialize_function_output(payload) { - Ok(output) => { - let content = - function_call_output_content_items_to_text(&output.body).unwrap_or_default(); - let duration = start.elapsed(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Ok(call_tool_result_from_content(&content, output.success)), - ) - .await; - Ok(output) - } - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) - } - }, - Err(err) => { - let duration = start.elapsed(); - let message = err.to_string(); - emit_tool_call_end( - &session, - turn.as_ref(), - &call_id, - invocation, - duration, - Err(message.clone()), - ) - .await; - Err(err) } } } diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index dea4b7dd8f..7f9119583b 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -38,20 +38,27 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use goal::GoalHandler; +pub use goal::CreateGoalHandler; +pub use goal::GetGoalHandler; +pub use goal::UpdateGoalHandler; pub use mcp::McpHandler; -pub use mcp_resource::McpResourceHandler; +pub use mcp_resource::ListMcpResourceTemplatesHandler; +pub use mcp_resource::ListMcpResourcesHandler; +pub use mcp_resource::ReadMcpResourceHandler; pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; +pub use shell::ContainerExecHandler; +pub use shell::LocalShellHandler; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; pub use tool_search::ToolSearchHandler; pub use unavailable_tool::UnavailableToolHandler; pub(crate) use unavailable_tool::unavailable_tool_message; -pub use unified_exec::UnifiedExecHandler; +pub use unified_exec::ExecCommandHandler; +pub use unified_exec::WriteStdinHandler; pub use view_image::ViewImageHandler; fn parse_arguments(arguments: &str) -> Result diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 2d70d3e92d..71ef84fd19 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -32,6 +32,7 @@ use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::user_input::UserInput; +use codex_tools::ToolName; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index b1ce67c69a..a0df373863 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -6,6 +6,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = CloseAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("close_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index b0466fa115..c4be06ecbb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -8,6 +8,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = ResumeAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("resume_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 1feb21b839..0994ba5e2f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -7,6 +7,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = SendInputResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("send_input") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 857918f4fe..6cb09e242c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -13,6 +13,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = SpawnAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("spawn_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 49b85dbfb3..8d6c09193e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -18,6 +18,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = WaitAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("wait_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index b561c5acb4..a477c25ca4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -22,6 +22,7 @@ use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::user_input::UserInput; +use codex_tools::ToolName; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 72d45c20d7..685c0cded5 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -6,6 +6,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = CloseAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("close_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs index bcb3f49dea..a5dfcb09dd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs @@ -9,6 +9,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("followup_task") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index 579c441993..dabfe72a7d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -6,6 +6,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = ListAgentsResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("list_agents") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 12e443b814..dcf1a1e583 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -62,15 +62,7 @@ pub(crate) async fn handle_message_string_tool( target: String, message: String, ) -> Result { - handle_message_submission(invocation, mode, target, message_content(message)?).await -} - -async fn handle_message_submission( - invocation: ToolInvocation, - mode: MessageDeliveryMode, - target: String, - prompt: String, -) -> Result { + let prompt = message_content(message)?; let ToolInvocation { session, turn, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs index b327ccf520..e814c69f5f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs @@ -9,6 +9,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("send_message") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index a08eea36e3..71e116b22a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -15,6 +15,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = SpawnAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("spawn_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index b86e237f53..706c0ad6ac 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -10,6 +10,10 @@ pub(crate) struct Handler; impl ToolHandler for Handler { type Output = WaitAgentResult; + fn tool_name(&self) -> ToolName { + ToolName::plain("wait_agent") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 71636229eb..ce217f457d 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -1,6 +1,4 @@ use crate::function_tool::FunctionCallError; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -11,6 +9,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::EventMsg; +use codex_tools::ToolName; use serde_json::Value as JsonValue; pub struct PlanHandler; @@ -46,6 +45,10 @@ impl ToolOutput for PlanToolOutput { impl ToolHandler for PlanHandler { type Output = PlanToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("update_plan") + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -54,7 +57,7 @@ impl ToolHandler for PlanHandler { let ToolInvocation { session, turn, - call_id, + call_id: _, payload, .. } = invocation; @@ -68,33 +71,21 @@ impl ToolHandler for PlanHandler { } }; - handle_update_plan(session.as_ref(), turn.as_ref(), arguments, call_id).await?; + if turn.collaboration_mode.mode == ModeKind::Plan { + return Err(FunctionCallError::RespondToModel( + "update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(), + )); + } + + let args = parse_update_plan_arguments(&arguments)?; + session + .send_event(turn.as_ref(), EventMsg::PlanUpdate(args)) + .await; Ok(PlanToolOutput) } } -/// This function doesn't do anything useful. However, it gives the model a structured way to record its plan that clients can read and render. -/// So it's the _inputs_ to this function that are useful to clients, not the outputs and neither are actually useful for the model other -/// than forcing it to come up and document a plan (TBD how that affects performance). -pub(crate) async fn handle_update_plan( - session: &Session, - turn_context: &TurnContext, - arguments: String, - _call_id: String, -) -> Result { - if turn_context.collaboration_mode.mode == ModeKind::Plan { - return Err(FunctionCallError::RespondToModel( - "update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(), - )); - } - let args = parse_update_plan_arguments(&arguments)?; - session - .send_event(turn_context, EventMsg::PlanUpdate(args)) - .await; - Ok("Plan updated".to_string()) -} - fn parse_update_plan_arguments(arguments: &str) -> Result { serde_json::from_str::(arguments).map_err(|e| { FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e}")) diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 56facee658..7b49ec5803 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -8,12 +8,17 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; pub struct RequestPermissionsHandler; impl ToolHandler for RequestPermissionsHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("request_permissions") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 673a73bfb7..879a8955b4 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -13,6 +13,7 @@ use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY; use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME; use codex_tools::RequestPluginInstallArgs; use codex_tools::RequestPluginInstallResult; +use codex_tools::ToolName; use codex_tools::all_requested_connectors_picked_up; use codex_tools::build_request_plugin_install_elicitation_request; use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; @@ -37,6 +38,10 @@ pub struct RequestPluginInstallHandler; impl ToolHandler for RequestPluginInstallHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index eea6612762..cd00dc272c 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -8,6 +8,7 @@ use crate::tools::registry::ToolKind; use codex_protocol::config_types::ModeKind; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_tools::REQUEST_USER_INPUT_TOOL_NAME; +use codex_tools::ToolName; use codex_tools::normalize_request_user_input_args; use codex_tools::request_user_input_unavailable_message; @@ -18,6 +19,10 @@ pub struct RequestUserInputHandler { impl ToolHandler for RequestUserInputHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index d498f36530..dfc3c3e5b9 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -40,8 +40,11 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ExecCommandSource; use codex_shell_command::is_safe_command::is_known_safe_command; use codex_tools::ShellCommandBackendConfig; +use codex_tools::ToolName; pub struct ShellHandler; +pub struct ContainerExecHandler; +pub struct LocalShellHandler; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShellCommandBackend { @@ -53,16 +56,24 @@ pub struct ShellCommandHandler { backend: ShellCommandBackend, } -fn shell_payload_command(payload: &ToolPayload) -> Option { - match payload { - ToolPayload::Function { arguments } => parse_arguments::(arguments) - .ok() - .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)), - ToolPayload::LocalShell { params } => Some(codex_shell_command::parse_command::shlex_join( - ¶ms.command, - )), - _ => None, - } +fn shell_function_payload_command(payload: &ToolPayload) -> Option { + let ToolPayload::Function { arguments } = payload else { + return None; + }; + + parse_arguments::(arguments) + .ok() + .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) +} + +fn local_shell_payload_command(payload: &ToolPayload) -> Option { + let ToolPayload::LocalShell { params } = payload else { + return None; + }; + + Some(codex_shell_command::parse_command::shlex_join( + ¶ms.command, + )) } fn shell_command_payload_command(payload: &ToolPayload) -> Option { @@ -182,31 +193,184 @@ impl From for ShellCommandHandler { impl ToolHandler for ShellHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("shell") + } + fn kind(&self) -> ToolKind { ToolKind::Function } fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!( - payload, - ToolPayload::Function { .. } | ToolPayload::LocalShell { .. } - ) + matches!(payload, ToolPayload::Function { .. }) } async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { - match &invocation.payload { - ToolPayload::Function { arguments } => { - serde_json::from_str::(arguments) - .map(|params| !is_known_safe_command(¶ms.command)) - .unwrap_or(true) - } - ToolPayload::LocalShell { params } => !is_known_safe_command(¶ms.command), - _ => true, // unknown payloads => assume mutating - } + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + + serde_json::from_str::(arguments) + .map(|params| !is_known_safe_command(¶ms.command)) + .unwrap_or(true) } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + shell_function_pre_tool_use_payload(invocation) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + shell_function_post_tool_use_payload(invocation, result) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "unsupported payload for shell handler".to_string(), + )); + } + }; + + let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; + let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; + let prefix_rule = params.prefix_rule.clone(); + let exec_params = + ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); + ShellHandler::run_exec_like(RunExecLikeArgs { + tool_name: "shell".to_string(), + exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), + additional_permissions: params.additional_permissions.clone(), + prefix_rule, + session, + turn, + tracker, + call_id, + freeform: false, + shell_runtime_backend: ShellRuntimeBackend::Generic, + }) + .await + } +} + +impl ToolHandler for ContainerExecHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("container.exec") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + + serde_json::from_str::(arguments) + .map(|params| !is_known_safe_command(¶ms.command)) + .unwrap_or(true) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + shell_function_pre_tool_use_payload(invocation) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + shell_function_post_tool_use_payload(invocation, result) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + tracker, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "unsupported payload for container.exec handler".to_string(), + )); + } + }; + + let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; + let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; + let prefix_rule = params.prefix_rule.clone(); + let exec_params = + ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); + ShellHandler::run_exec_like(RunExecLikeArgs { + tool_name: "container.exec".to_string(), + exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), + additional_permissions: params.additional_permissions.clone(), + prefix_rule, + session, + turn, + tracker, + call_id, + freeform: false, + shell_runtime_backend: ShellRuntimeBackend::Generic, + }) + .await + } +} + +impl ToolHandler for LocalShellHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("local_shell") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::LocalShell { .. }) + } + + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::LocalShell { params } = &invocation.payload else { + return true; + }; + + !is_known_safe_command(¶ms.command) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + local_shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { tool_name: HookToolName::bash(), tool_input: serde_json::json!({ "command": command }), }) @@ -219,7 +383,7 @@ impl ToolHandler for ShellHandler { ) -> Option { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = shell_payload_command(&invocation.payload)?; + let command = local_shell_payload_command(&invocation.payload)?; Some(PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id: invocation.call_id.clone(), @@ -234,62 +398,63 @@ impl ToolHandler for ShellHandler { turn, tracker, call_id, - tool_name, payload, .. } = invocation; - match payload { - ToolPayload::Function { arguments } => { - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = - Self::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - Self::run_exec_like(RunExecLikeArgs { - tool_name: tool_name.display(), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } - ToolPayload::LocalShell { params } => { - let exec_params = - Self::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - Self::run_exec_like(RunExecLikeArgs { - tool_name: tool_name.display(), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: None, - prefix_rule: None, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } - _ => Err(FunctionCallError::RespondToModel(format!( - "unsupported payload for shell handler: {}", - tool_name.display() - ))), - } + let ToolPayload::LocalShell { params } = payload else { + return Err(FunctionCallError::RespondToModel( + "unsupported payload for local_shell handler".to_string(), + )); + }; + + let exec_params = + ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); + ShellHandler::run_exec_like(RunExecLikeArgs { + tool_name: "local_shell".to_string(), + exec_params, + hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), + additional_permissions: None, + prefix_rule: None, + session, + turn, + tracker, + call_id, + freeform: false, + shell_runtime_backend: ShellRuntimeBackend::Generic, + }) + .await } } +fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option { + shell_function_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: HookToolName::bash(), + tool_input: serde_json::json!({ "command": command }), + }) +} + +fn shell_function_post_tool_use_payload( + invocation: &ToolInvocation, + result: &FunctionToolOutput, +) -> Option { + let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let command = shell_function_payload_command(&invocation.payload)?; + Some(PostToolUsePayload { + tool_name: HookToolName::bash(), + tool_use_id: invocation.call_id.clone(), + tool_input: serde_json::json!({ "command": command }), + tool_response, + }) +} + impl ToolHandler for ShellCommandHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("shell_command") + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -348,7 +513,6 @@ impl ToolHandler for ShellCommandHandler { turn, tracker, call_id, - tool_name, payload, .. } = invocation; @@ -356,7 +520,7 @@ impl ToolHandler for ShellCommandHandler { let ToolPayload::Function { arguments } = payload else { return Err(FunctionCallError::RespondToModel(format!( "unsupported payload for shell_command handler: {}", - tool_name.display() + self.tool_name().display() ))); }; @@ -379,7 +543,7 @@ impl ToolHandler for ShellCommandHandler { turn.tools_config.allow_login_shell, )?; ShellHandler::run_exec_like(RunExecLikeArgs { - tool_name: tool_name.display(), + tool_name: self.tool_name().display(), exec_params, hook_command: params.command, additional_permissions: params.additional_permissions.clone(), diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 49e2cf8f75..8a32e5404b 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -16,8 +16,8 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::ShellCommandHandler; -use crate::tools::handlers::ShellHandler; use crate::tools::hook_names::HookToolName; use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; @@ -204,7 +204,7 @@ fn shell_command_handler_rejects_login_when_disallowed() { } #[tokio::test] -async fn shell_pre_tool_use_payload_uses_joined_command() { +async fn local_shell_pre_tool_use_payload_uses_joined_command() { let payload = ToolPayload::LocalShell { params: codex_protocol::models::ShellToolCallParams { command: vec![ @@ -215,13 +215,13 @@ async fn shell_pre_tool_use_payload_uses_joined_command() { workdir: None, timeout_ms: None, sandbox_permissions: None, - prefix_rule: None, additional_permissions: None, + prefix_rule: None, justification: None, }, }; let (session, turn) = make_session_and_context().await; - let handler = ShellHandler; + let handler = LocalShellHandler; assert_eq!( handler.pre_tool_use_payload(&ToolInvocation { @@ -230,7 +230,7 @@ async fn shell_pre_tool_use_payload_uses_joined_command() { cancellation_token: tokio_util::sync::CancellationToken::new(), tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), call_id: "call-41".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + tool_name: codex_tools::ToolName::plain("local_shell"), source: crate::tools::context::ToolCallSource::Direct, payload, }), diff --git a/codex-rs/core/src/tools/handlers/test_sync.rs b/codex-rs/core/src/tools/handlers/test_sync.rs index ad2647243a..e04400d17e 100644 --- a/codex-rs/core/src/tools/handlers/test_sync.rs +++ b/codex-rs/core/src/tools/handlers/test_sync.rs @@ -15,6 +15,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; pub struct TestSyncHandler; @@ -56,6 +57,10 @@ fn barrier_map() -> &'static tokio::sync::Mutex> { impl ToolHandler for TestSyncHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("test_sync_tool") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 67bc7b7f27..59deb54116 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -12,6 +12,7 @@ use bm25::SearchEngineBuilder; use codex_tools::LoadableToolSpec; use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT; use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::ToolName; use codex_tools::coalesce_loadable_tool_specs; use std::collections::HashMap; @@ -44,6 +45,10 @@ impl ToolSearchHandler { impl ToolHandler for ToolSearchHandler { type Output = ToolSearchOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain(TOOL_SEARCH_TOOL_NAME) + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/handlers/unavailable_tool.rs b/codex-rs/core/src/tools/handlers/unavailable_tool.rs index eb00cf8ff1..64bb200581 100644 --- a/codex-rs/core/src/tools/handlers/unavailable_tool.rs +++ b/codex-rs/core/src/tools/handlers/unavailable_tool.rs @@ -4,8 +4,17 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; -pub struct UnavailableToolHandler; +pub struct UnavailableToolHandler { + tool_name: ToolName, +} + +impl UnavailableToolHandler { + pub fn new(tool_name: ToolName) -> Self { + Self { tool_name } + } +} pub(crate) fn unavailable_tool_message( tool_name: impl std::fmt::Display, @@ -19,19 +28,21 @@ pub(crate) fn unavailable_tool_message( impl ToolHandler for UnavailableToolHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Function } async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - tool_name, payload, .. - } = invocation; + let ToolInvocation { payload, .. } = invocation; match payload { ToolPayload::Function { .. } => Ok(FunctionToolOutput::from_text( unavailable_tool_message( - tool_name.display(), + self.tool_name.display(), "Retry after the tool becomes available or ask the user to re-enable it.", ), Some(false), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index f109f22ac6..c257240a4d 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -33,6 +33,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TerminalInteractionEvent; use codex_shell_command::is_safe_command::is_known_safe_command; +use codex_tools::ToolName; use codex_tools::UnifiedExecShellMode; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; @@ -40,7 +41,8 @@ use serde::Deserialize; use std::path::PathBuf; use std::sync::Arc; -pub struct UnifiedExecHandler; +pub struct ExecCommandHandler; +pub struct WriteStdinHandler; #[derive(Debug, Deserialize)] pub(crate) struct ExecCommandArgs { @@ -108,9 +110,13 @@ fn effective_max_output_tokens( resolve_max_tokens(max_output_tokens).min(truncation_policy.token_budget()) } -impl ToolHandler for UnifiedExecHandler { +impl ToolHandler for ExecCommandHandler { type Output = ExecCommandToolOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("exec_command") + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -144,12 +150,6 @@ impl ToolHandler for UnifiedExecHandler { } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - if invocation.tool_name.namespace.is_some() - || invocation.tool_name.name.as_str() != "exec_command" - { - return None; - } - let ToolPayload::Function { arguments } = &invocation.payload else { return None; }; @@ -167,23 +167,7 @@ impl ToolHandler for UnifiedExecHandler { invocation: &ToolInvocation, result: &Self::Output, ) -> Option { - let ToolPayload::Function { .. } = &invocation.payload else { - return None; - }; - - let command = result.hook_command.clone()?; - let tool_use_id = if result.event_call_id.is_empty() { - invocation.call_id.clone() - } else { - result.event_call_id.clone() - }; - let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id, - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) + post_unified_exec_tool_use_payload(invocation, result) } async fn handle(&self, invocation: ToolInvocation) -> Result { @@ -192,7 +176,6 @@ impl ToolHandler for UnifiedExecHandler { turn, tracker, call_id, - tool_name, payload, .. } = invocation; @@ -201,239 +184,292 @@ impl ToolHandler for UnifiedExecHandler { ToolPayload::Function { arguments } => arguments, _ => { return Err(FunctionCallError::RespondToModel( - "unified_exec handler received unsupported payload".to_string(), + "exec_command handler received unsupported payload".to_string(), )); } }; let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager; let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone()); + let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?; + let Some(turn_environment) = + resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())? + else { + return Err(FunctionCallError::RespondToModel( + "unified exec is unavailable in this session".to_string(), + )); + }; + let cwd = environment_args + .workdir + .as_deref() + .filter(|workdir| !workdir.is_empty()) + .map_or_else( + || turn_environment.cwd.clone(), + |workdir| turn_environment.cwd.join(workdir), + ); + let environment = Arc::clone(&turn_environment.environment); + let fs = environment.get_filesystem(); + let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; + let hook_command = args.cmd.clone(); + maybe_emit_implicit_skill_invocation( + session.as_ref(), + context.turn.as_ref(), + &hook_command, + &cwd, + ) + .await; + let process_id = manager.allocate_process_id().await; + let command = get_command( + &args, + session.user_shell(), + &turn.tools_config.unified_exec_shell_mode, + turn.tools_config.allow_login_shell, + ) + .map_err(FunctionCallError::RespondToModel)?; + let command_for_display = codex_shell_command::parse_command::shlex_join(&command); - let response = match tool_name.name.as_str() { - "exec_command" => { - let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?; - let Some(turn_environment) = resolve_tool_environment( - turn.as_ref(), - environment_args.environment_id.as_deref(), - )? - else { - return Err(FunctionCallError::RespondToModel( - "unified exec is unavailable in this session".to_string(), - )); - }; - let cwd = environment_args - .workdir - .as_deref() - .filter(|workdir| !workdir.is_empty()) - .map_or_else( - || turn_environment.cwd.clone(), - |workdir| turn_environment.cwd.join(workdir), - ); - let environment = Arc::clone(&turn_environment.environment); - let fs = environment.get_filesystem(); - let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; - let hook_command = args.cmd.clone(); - maybe_emit_implicit_skill_invocation( - session.as_ref(), - context.turn.as_ref(), - &hook_command, + let ExecCommandArgs { + tty, + yield_time_ms, + max_output_tokens, + sandbox_permissions, + additional_permissions, + justification, + prefix_rule, + .. + } = args; + let max_output_tokens = + effective_max_output_tokens(max_output_tokens, turn.truncation_policy); + + let exec_permission_approvals_enabled = + session.features().enabled(Feature::ExecPermissionApprovals); + let requested_additional_permissions = additional_permissions.clone(); + let effective_additional_permissions = apply_granted_turn_permissions( + context.session.as_ref(), + cwd.as_path(), + sandbox_permissions, + additional_permissions, + ) + .await; + let additional_permissions_allowed = exec_permission_approvals_enabled + || (session.features().enabled(Feature::RequestPermissionsTool) + && effective_additional_permissions.permissions_preapproved); + + // Sticky turn permissions have already been approved, so they should + // continue through the normal exec approval flow for the command. + if effective_additional_permissions + .sandbox_permissions + .requests_sandbox_override() + && !effective_additional_permissions.permissions_preapproved + && !matches!( + context.turn.approval_policy.value(), + codex_protocol::protocol::AskForApproval::OnRequest + ) + { + let approval_policy = context.turn.approval_policy.value(); + manager.release_process_id(process_id).await; + return Err(FunctionCallError::RespondToModel(format!( + "approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}" + ))); + } + + let normalized_additional_permissions = match implicit_granted_permissions( + sandbox_permissions, + requested_additional_permissions.as_ref(), + &effective_additional_permissions, + ) + .map_or_else( + || { + normalize_and_validate_additional_permissions( + additional_permissions_allowed, + context.turn.approval_policy.value(), + effective_additional_permissions.sandbox_permissions, + effective_additional_permissions.additional_permissions, + effective_additional_permissions.permissions_preapproved, &cwd, ) - .await; - let process_id = manager.allocate_process_id().await; - let command = get_command( - &args, - session.user_shell(), - &turn.tools_config.unified_exec_shell_mode, - turn.tools_config.allow_login_shell, - ) - .map_err(FunctionCallError::RespondToModel)?; - let command_for_display = codex_shell_command::parse_command::shlex_join(&command); - - let ExecCommandArgs { - tty, - yield_time_ms, - max_output_tokens, - sandbox_permissions, - additional_permissions, - justification, - prefix_rule, - .. - } = args; - let max_output_tokens = - effective_max_output_tokens(max_output_tokens, turn.truncation_policy); - - let exec_permission_approvals_enabled = - session.features().enabled(Feature::ExecPermissionApprovals); - let requested_additional_permissions = additional_permissions.clone(); - let effective_additional_permissions = apply_granted_turn_permissions( - context.session.as_ref(), - cwd.as_path(), - sandbox_permissions, - additional_permissions, - ) - .await; - let additional_permissions_allowed = exec_permission_approvals_enabled - || (session.features().enabled(Feature::RequestPermissionsTool) - && effective_additional_permissions.permissions_preapproved); - - // Sticky turn permissions have already been approved, so they should - // continue through the normal exec approval flow for the command. - if effective_additional_permissions - .sandbox_permissions - .requests_sandbox_override() - && !effective_additional_permissions.permissions_preapproved - && !matches!( - context.turn.approval_policy.value(), - codex_protocol::protocol::AskForApproval::OnRequest - ) - { - let approval_policy = context.turn.approval_policy.value(); - manager.release_process_id(process_id).await; - return Err(FunctionCallError::RespondToModel(format!( - "approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}" - ))); - } - - let normalized_additional_permissions = match implicit_granted_permissions( - sandbox_permissions, - requested_additional_permissions.as_ref(), - &effective_additional_permissions, - ) - .map_or_else( - || { - normalize_and_validate_additional_permissions( - additional_permissions_allowed, - context.turn.approval_policy.value(), - effective_additional_permissions.sandbox_permissions, - effective_additional_permissions.additional_permissions, - effective_additional_permissions.permissions_preapproved, - &cwd, - ) - }, - |permissions| Ok(Some(permissions)), - ) { - Ok(normalized) => normalized, - Err(err) => { - manager.release_process_id(process_id).await; - return Err(FunctionCallError::RespondToModel(err)); - } - }; - - if let Some(output) = intercept_apply_patch( - &command, - &cwd, - fs.as_ref(), - context.session.clone(), - context.turn.clone(), - Some(&tracker), - &context.call_id, - &tool_name.name, - ) - .await? - { - manager.release_process_id(process_id).await; - return Ok(ExecCommandToolOutput { - event_call_id: String::new(), - chunk_id: String::new(), - wall_time: std::time::Duration::ZERO, - raw_output: output.into_text().into_bytes(), - max_output_tokens: Some(max_output_tokens), - process_id: None, - exit_code: None, - original_token_count: None, - hook_command: None, - }); - } - - emit_unified_exec_tty_metric(&turn.session_telemetry, tty); - match manager - .exec_command( - ExecCommandRequest { - command, - hook_command: hook_command.clone(), - process_id, - yield_time_ms, - max_output_tokens: Some(max_output_tokens), - cwd, - environment, - network: context.turn.network.clone(), - tty, - sandbox_permissions: effective_additional_permissions - .sandbox_permissions, - additional_permissions: normalized_additional_permissions, - additional_permissions_preapproved: effective_additional_permissions - .permissions_preapproved, - justification, - prefix_rule, - }, - &context, - ) - .await - { - Ok(response) => response, - Err(UnifiedExecError::SandboxDenied { output, .. }) => { - let output_text = output.aggregated_output.text; - let original_token_count = approx_token_count(&output_text); - ExecCommandToolOutput { - event_call_id: context.call_id.clone(), - chunk_id: generate_chunk_id(), - wall_time: output.duration, - raw_output: output_text.into_bytes(), - max_output_tokens: Some(max_output_tokens), - // Sandbox denial is terminal, so there is no live - // process for write_stdin to resume. - process_id: None, - exit_code: Some(output.exit_code), - original_token_count: Some(original_token_count), - hook_command: Some(hook_command), - } - } - Err(err) => { - return Err(FunctionCallError::RespondToModel(format!( - "exec_command failed for `{command_for_display}`: {err:?}" - ))); - } - } - } - "write_stdin" => { - let args: WriteStdinArgs = parse_arguments(&arguments)?; - let max_output_tokens = - effective_max_output_tokens(args.max_output_tokens, turn.truncation_policy); - let response = manager - .write_stdin(WriteStdinRequest { - process_id: args.session_id, - input: &args.chars, - yield_time_ms: args.yield_time_ms, - max_output_tokens: Some(max_output_tokens), - }) - .await - .map_err(|err| { - FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) - })?; - - let interaction = TerminalInteractionEvent { - call_id: response.event_call_id.clone(), - process_id: args.session_id.to_string(), - stdin: args.chars.clone(), - }; - session - .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) - .await; - - response - } - other => { - return Err(FunctionCallError::RespondToModel(format!( - "unsupported unified exec function {other}" - ))); + }, + |permissions| Ok(Some(permissions)), + ) { + Ok(normalized) => normalized, + Err(err) => { + manager.release_process_id(process_id).await; + return Err(FunctionCallError::RespondToModel(err)); } }; + if let Some(output) = intercept_apply_patch( + &command, + &cwd, + fs.as_ref(), + context.session.clone(), + context.turn.clone(), + Some(&tracker), + &context.call_id, + "exec_command", + ) + .await? + { + manager.release_process_id(process_id).await; + return Ok(ExecCommandToolOutput { + event_call_id: String::new(), + chunk_id: String::new(), + wall_time: std::time::Duration::ZERO, + raw_output: output.into_text().into_bytes(), + max_output_tokens: Some(max_output_tokens), + process_id: None, + exit_code: None, + original_token_count: None, + hook_command: None, + }); + } + + emit_unified_exec_tty_metric(&turn.session_telemetry, tty); + match manager + .exec_command( + ExecCommandRequest { + command, + hook_command: hook_command.clone(), + process_id, + yield_time_ms, + max_output_tokens: Some(max_output_tokens), + cwd, + environment, + network: context.turn.network.clone(), + tty, + sandbox_permissions: effective_additional_permissions.sandbox_permissions, + additional_permissions: normalized_additional_permissions, + additional_permissions_preapproved: effective_additional_permissions + .permissions_preapproved, + justification, + prefix_rule, + }, + &context, + ) + .await + { + Ok(response) => Ok(response), + Err(UnifiedExecError::SandboxDenied { output, .. }) => { + let output_text = output.aggregated_output.text; + let original_token_count = approx_token_count(&output_text); + Ok(ExecCommandToolOutput { + event_call_id: context.call_id.clone(), + chunk_id: generate_chunk_id(), + wall_time: output.duration, + raw_output: output_text.into_bytes(), + max_output_tokens: Some(max_output_tokens), + // Sandbox denial is terminal, so there is no live + // process for write_stdin to resume. + process_id: None, + exit_code: Some(output.exit_code), + original_token_count: Some(original_token_count), + hook_command: Some(hook_command), + }) + } + Err(err) => Err(FunctionCallError::RespondToModel(format!( + "exec_command failed for `{command_for_display}`: {err:?}" + ))), + } + } +} + +impl ToolHandler for WriteStdinHandler { + type Output = ExecCommandToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain("write_stdin") + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + true + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + post_unified_exec_tool_use_payload(invocation, result) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "write_stdin handler received unsupported payload".to_string(), + )); + } + }; + + let args: WriteStdinArgs = parse_arguments(&arguments)?; + let max_output_tokens = + effective_max_output_tokens(args.max_output_tokens, turn.truncation_policy); + let response = session + .services + .unified_exec_manager + .write_stdin(WriteStdinRequest { + process_id: args.session_id, + input: &args.chars, + yield_time_ms: args.yield_time_ms, + max_output_tokens: Some(max_output_tokens), + }) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) + })?; + + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: args.session_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + Ok(response) } } +fn post_unified_exec_tool_use_payload( + invocation: &ToolInvocation, + result: &ExecCommandToolOutput, +) -> Option { + let ToolPayload::Function { .. } = &invocation.payload else { + return None; + }; + + let command = result.hook_command.clone()?; + let tool_use_id = if result.event_call_id.is_empty() { + invocation.call_id.clone() + } else { + result.event_call_id.clone() + }; + let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?; + Some(PostToolUsePayload { + tool_name: HookToolName::bash(), + tool_use_id, + tool_input: serde_json::json!({ "command": command }), + tool_response, + }) +} + fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) { session_telemetry.counter( TOOL_CALL_UNIFIED_EXEC_METRIC, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 70b933bad0..8818b2e344 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -184,7 +184,7 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() { arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(), }; let (session, turn) = make_session_and_context().await; - let handler = UnifiedExecHandler; + let handler = ExecCommandHandler; assert_eq!( handler.pre_tool_use_payload(&ToolInvocation { @@ -210,7 +210,7 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() { arguments: serde_json::json!({ "chars": "echo hi" }).to_string(), }; let (session, turn) = make_session_and_context().await; - let handler = UnifiedExecHandler; + let handler = WriteStdinHandler; assert_eq!( handler.pre_tool_use_payload(&ToolInvocation { @@ -244,8 +244,9 @@ async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_s hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-43", payload).await; + let handler = ExecCommandHandler; assert_eq!( - UnifiedExecHandler.post_tool_use_payload(&invocation, &output), + handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id: "call-43".to_string(), @@ -272,9 +273,10 @@ async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completi hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-44", payload).await; + let handler = ExecCommandHandler; assert_eq!( - UnifiedExecHandler.post_tool_use_payload(&invocation, &output), + handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id: "call-44".to_string(), @@ -301,10 +303,8 @@ async fn exec_command_post_tool_use_payload_skips_running_sessions() { hook_command: Some("echo three".to_string()), }; let invocation = invocation_for_payload("exec_command", "call-45", payload).await; - assert_eq!( - UnifiedExecHandler.post_tool_use_payload(&invocation, &output), - None - ); + let handler = ExecCommandHandler; + assert_eq!(handler.post_tool_use_payload(&invocation, &output), None); } #[tokio::test] @@ -328,9 +328,10 @@ async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_comman hook_command: Some("sleep 1; echo finished".to_string()), }; let invocation = invocation_for_payload("write_stdin", "write-stdin-call", payload).await; + let handler = WriteStdinHandler; assert_eq!( - UnifiedExecHandler.post_tool_use_payload(&invocation, &output), + handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id: "exec-call-45".to_string(), @@ -369,10 +370,11 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ }; let invocation_b = invocation_for_payload("write_stdin", "write-call-b", payload.clone()).await; let invocation_a = invocation_for_payload("write_stdin", "write-call-a", payload).await; + let handler = WriteStdinHandler; let payloads = [ - UnifiedExecHandler.post_tool_use_payload(&invocation_b, &output_b), - UnifiedExecHandler.post_tool_use_payload(&invocation_a, &output_a), + handler.post_tool_use_payload(&invocation_b, &output_b), + handler.post_tool_use_payload(&invocation_a, &output_a), ]; assert_eq!( diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index d05807ef78..a7cbe7d975 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -19,6 +19,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_tools::ToolName; pub struct ViewImageHandler; @@ -39,6 +40,10 @@ enum ViewImageDetail { impl ToolHandler for ViewImageHandler { type Output = ViewImageOutput; + fn tool_name(&self) -> ToolName { + ToolName::plain("view_image") + } + fn kind(&self) -> ToolKind { ToolKind::Function } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 87b36ff17e..bdf18cf2fe 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -44,6 +44,9 @@ pub enum ToolKind { pub trait ToolHandler: Send + Sync { type Output: ToolOutput + 'static; + /// The concrete tool name handled by this handler instance. + fn tool_name(&self) -> ToolName; + fn kind(&self) -> ToolKind; fn matches_kind(&self, payload: &ToolPayload) -> bool { @@ -227,10 +230,11 @@ impl ToolRegistry { } #[cfg(test)] - pub(crate) fn with_handler_for_test(name: ToolName, handler: Arc) -> Self + pub(crate) fn with_handler_for_test(handler: Arc) -> Self where T: ToolHandler + 'static, { + let name = handler.tool_name(); Self::new(HashMap::from([(name, handler as Arc)])) } @@ -250,14 +254,6 @@ impl ToolRegistry { self.handler(name)?.create_diff_consumer() } - // TODO(jif) for dynamic tools. - // pub fn register(&mut self, name: impl Into, handler: Arc) { - // let name = name.into(); - // if self.handlers.insert(name.clone(), handler).is_some() { - // warn!("overwriting handler for tool {name}"); - // } - // } - #[expect( clippy::await_holding_invalid_type, reason = "tool dispatch must keep active-turn accounting atomic" @@ -539,11 +535,11 @@ impl ToolRegistryBuilder { .push(ConfiguredToolSpec::new(spec, supports_parallel_tool_calls)); } - pub fn register_handler(&mut self, name: impl Into, handler: Arc) + pub fn register_handler(&mut self, handler: Arc) where H: ToolHandler + 'static, { - let name = name.into(); + let name = handler.tool_name(); let display_name = name.display(); let handler: Arc = handler; if self.handlers.insert(name, handler).is_some() { @@ -551,24 +547,6 @@ impl ToolRegistryBuilder { } } - // TODO(jif) for dynamic tools. - // pub fn register_many(&mut self, names: I, handler: Arc) - // where - // I: IntoIterator, - // I::Item: Into, - // { - // for name in names { - // let name = name.into(); - // if self - // .handlers - // .insert(name.clone(), handler.clone()) - // .is_some() - // { - // warn!("overwriting handler for tool {name}"); - // } - // } - // } - pub fn build(self) -> (Vec, ToolRegistry) { let registry = ToolRegistry::new(self.handlers); (self.specs, registry) diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index d44c3d0f9b..ef7273999d 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,12 +1,17 @@ use super::*; use pretty_assertions::assert_eq; -#[derive(Default)] -struct TestHandler; +struct TestHandler { + tool_name: codex_tools::ToolName, +} impl ToolHandler for TestHandler { type Output = crate::tools::context::FunctionToolOutput; + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -21,12 +26,16 @@ impl ToolHandler for TestHandler { #[test] fn handler_looks_up_namespaced_aliases_explicitly() { - let plain_handler = Arc::new(TestHandler) as Arc; - let namespaced_handler = Arc::new(TestHandler) as Arc; let namespace = "mcp__codex_apps__gmail"; let tool_name = "gmail_get_recent_emails"; let plain_name = codex_tools::ToolName::plain(tool_name); let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name); + let plain_handler = Arc::new(TestHandler { + tool_name: plain_name.clone(), + }) as Arc; + let namespaced_handler = Arc::new(TestHandler { + tool_name: namespaced_name.clone(), + }) as Arc; let registry = ToolRegistry::new(HashMap::from([ (plain_name.clone(), Arc::clone(&plain_handler)), (namespaced_name.clone(), Arc::clone(&namespaced_handler)), diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 83e8600e02..994e20ccd0 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,6 +1,7 @@ use crate::shell::Shell; use crate::shell::ShellType; -use crate::tools::handlers::agent_jobs::BatchJobHandler; +use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; +use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler; use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; @@ -76,11 +77,17 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; + use crate::tools::handlers::ContainerExecHandler; + use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; - use crate::tools::handlers::GoalHandler; + use crate::tools::handlers::ExecCommandHandler; + use crate::tools::handlers::GetGoalHandler; + use crate::tools::handlers::ListMcpResourceTemplatesHandler; + use crate::tools::handlers::ListMcpResourcesHandler; + use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::McpHandler; - use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; + use crate::tools::handlers::ReadMcpResourceHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; @@ -89,8 +96,9 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; use crate::tools::handlers::UnavailableToolHandler; - use crate::tools::handlers::UnifiedExecHandler; + use crate::tools::handlers::UpdateGoalHandler; use crate::tools::handlers::ViewImageHandler; + use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::multi_agents::CloseAgentHandler; use crate::tools::handlers::multi_agents::ResumeAgentHandler; use crate::tools::handlers::multi_agents::SendInputHandler; @@ -150,30 +158,11 @@ pub(crate) fn build_specs_with_discoverable_tools( }, }, ); - let shell_handler = Arc::new(ShellHandler); - let unified_exec_handler = Arc::new(UnifiedExecHandler); - let plan_handler = Arc::new(PlanHandler); - let apply_patch_handler = Arc::new(ApplyPatchHandler); - let dynamic_tool_handler = Arc::new(DynamicToolHandler); - let goal_handler = Arc::new(GoalHandler); - let view_image_handler = Arc::new(ViewImageHandler); - let mcp_handler = Arc::new(McpHandler); - let mcp_resource_handler = Arc::new(McpResourceHandler); - let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend)); - let request_permissions_handler = Arc::new(RequestPermissionsHandler); - let request_user_input_handler = Arc::new(RequestUserInputHandler { - available_modes: config.request_user_input_available_modes.clone(), - }); let deferred_dynamic_tools = dynamic_tools .iter() .filter(|tool| tool.defer_loading && (config.namespace_tools || tool.namespace.is_none())) .cloned() .collect::>(); - let mut tool_search_handler = None; - let request_plugin_install_handler = Arc::new(RequestPluginInstallHandler); - let code_mode_handler = Arc::new(CodeModeExecuteHandler); - let code_mode_wait_handler = Arc::new(CodeModeWaitHandler); - let unavailable_tool_handler = Arc::new(UnavailableToolHandler); let mut existing_spec_names = plan .specs .iter() @@ -191,113 +180,137 @@ pub(crate) fn build_specs_with_discoverable_tools( } for handler in plan.handlers { + let name = handler.name; match handler.kind { - ToolHandlerKind::AgentJobs => { - builder.register_handler(handler.name, Arc::new(BatchJobHandler)); - } ToolHandlerKind::ApplyPatch => { - builder.register_handler(handler.name, apply_patch_handler.clone()); + builder.register_handler(Arc::new(ApplyPatchHandler)); } ToolHandlerKind::CloseAgentV1 => { - builder.register_handler(handler.name, Arc::new(CloseAgentHandler)); + builder.register_handler(Arc::new(CloseAgentHandler)); } ToolHandlerKind::CloseAgentV2 => { - builder.register_handler(handler.name, Arc::new(CloseAgentHandlerV2)); + builder.register_handler(Arc::new(CloseAgentHandlerV2)); } ToolHandlerKind::CodeModeExecute => { - builder.register_handler(handler.name, code_mode_handler.clone()); + builder.register_handler(Arc::new(CodeModeExecuteHandler)); } ToolHandlerKind::CodeModeWait => { - builder.register_handler(handler.name, code_mode_wait_handler.clone()); + builder.register_handler(Arc::new(CodeModeWaitHandler)); + } + ToolHandlerKind::ContainerExec => { + builder.register_handler(Arc::new(ContainerExecHandler)); + } + ToolHandlerKind::CreateGoal => { + builder.register_handler(Arc::new(CreateGoalHandler)); } ToolHandlerKind::DynamicTool => { - builder.register_handler(handler.name, dynamic_tool_handler.clone()); + builder.register_handler(Arc::new(DynamicToolHandler::new(name))); + } + ToolHandlerKind::ExecCommand => { + builder.register_handler(Arc::new(ExecCommandHandler)); } ToolHandlerKind::FollowupTaskV2 => { - builder.register_handler(handler.name, Arc::new(FollowupTaskHandlerV2)); + builder.register_handler(Arc::new(FollowupTaskHandlerV2)); } - ToolHandlerKind::Goal => { - builder.register_handler(handler.name, goal_handler.clone()); + ToolHandlerKind::GetGoal => { + builder.register_handler(Arc::new(GetGoalHandler)); } ToolHandlerKind::ListAgentsV2 => { - builder.register_handler(handler.name, Arc::new(ListAgentsHandlerV2)); + builder.register_handler(Arc::new(ListAgentsHandlerV2)); + } + ToolHandlerKind::ListMcpResources => { + builder.register_handler(Arc::new(ListMcpResourcesHandler)); + } + ToolHandlerKind::ListMcpResourceTemplates => { + builder.register_handler(Arc::new(ListMcpResourceTemplatesHandler)); + } + ToolHandlerKind::LocalShell => { + builder.register_handler(Arc::new(LocalShellHandler)); } ToolHandlerKind::Mcp => { - builder.register_handler(handler.name, mcp_handler.clone()); - } - ToolHandlerKind::McpResource => { - builder.register_handler(handler.name, mcp_resource_handler.clone()); + builder.register_handler(Arc::new(McpHandler::new(name))); } ToolHandlerKind::Plan => { - builder.register_handler(handler.name, plan_handler.clone()); + builder.register_handler(Arc::new(PlanHandler)); + } + ToolHandlerKind::ReadMcpResource => { + builder.register_handler(Arc::new(ReadMcpResourceHandler)); + } + ToolHandlerKind::ReportAgentJobResult => { + builder.register_handler(Arc::new(ReportAgentJobResultHandler)); } ToolHandlerKind::RequestPermissions => { - builder.register_handler(handler.name, request_permissions_handler.clone()); + builder.register_handler(Arc::new(RequestPermissionsHandler)); } ToolHandlerKind::RequestUserInput => { - builder.register_handler(handler.name, request_user_input_handler.clone()); + builder.register_handler(Arc::new(RequestUserInputHandler { + available_modes: config.request_user_input_available_modes.clone(), + })); } ToolHandlerKind::ResumeAgentV1 => { - builder.register_handler(handler.name, Arc::new(ResumeAgentHandler)); + builder.register_handler(Arc::new(ResumeAgentHandler)); } ToolHandlerKind::SendInputV1 => { - builder.register_handler(handler.name, Arc::new(SendInputHandler)); + builder.register_handler(Arc::new(SendInputHandler)); } ToolHandlerKind::SendMessageV2 => { - builder.register_handler(handler.name, Arc::new(SendMessageHandlerV2)); + builder.register_handler(Arc::new(SendMessageHandlerV2)); } ToolHandlerKind::Shell => { - builder.register_handler(handler.name, shell_handler.clone()); + builder.register_handler(Arc::new(ShellHandler)); } ToolHandlerKind::ShellCommand => { - builder.register_handler(handler.name, shell_command_handler.clone()); + builder.register_handler(Arc::new(ShellCommandHandler::from( + config.shell_command_backend, + ))); + } + ToolHandlerKind::SpawnAgentsOnCsv => { + builder.register_handler(Arc::new(SpawnAgentsOnCsvHandler)); } ToolHandlerKind::SpawnAgentV1 => { - builder.register_handler(handler.name, Arc::new(SpawnAgentHandler)); + builder.register_handler(Arc::new(SpawnAgentHandler)); } ToolHandlerKind::SpawnAgentV2 => { - builder.register_handler(handler.name, Arc::new(SpawnAgentHandlerV2)); + builder.register_handler(Arc::new(SpawnAgentHandlerV2)); } ToolHandlerKind::TestSync => { - builder.register_handler(handler.name, Arc::new(TestSyncHandler)); + builder.register_handler(Arc::new(TestSyncHandler)); } ToolHandlerKind::ToolSearch => { - if tool_search_handler.is_none() { - let entries = build_tool_search_entries_for_config( - config, - deferred_mcp_tools.as_ref(), - &deferred_dynamic_tools, - ); - tool_search_handler = Some(Arc::new(ToolSearchHandler::new(entries))); - } - if let Some(tool_search_handler) = tool_search_handler.as_ref() { - builder.register_handler(handler.name, tool_search_handler.clone()); - } + let entries = build_tool_search_entries_for_config( + config, + deferred_mcp_tools.as_ref(), + &deferred_dynamic_tools, + ); + builder.register_handler(Arc::new(ToolSearchHandler::new(entries))); } ToolHandlerKind::RequestPluginInstall => { - builder.register_handler(handler.name, request_plugin_install_handler.clone()); + builder.register_handler(Arc::new(RequestPluginInstallHandler)); } - ToolHandlerKind::UnifiedExec => { - builder.register_handler(handler.name, unified_exec_handler.clone()); + ToolHandlerKind::UpdateGoal => { + builder.register_handler(Arc::new(UpdateGoalHandler)); } ToolHandlerKind::ViewImage => { - builder.register_handler(handler.name, view_image_handler.clone()); + builder.register_handler(Arc::new(ViewImageHandler)); } ToolHandlerKind::WaitAgentV1 => { - builder.register_handler(handler.name, Arc::new(WaitAgentHandler)); + builder.register_handler(Arc::new(WaitAgentHandler)); } ToolHandlerKind::WaitAgentV2 => { - builder.register_handler(handler.name, Arc::new(WaitAgentHandlerV2)); + builder.register_handler(Arc::new(WaitAgentHandlerV2)); + } + ToolHandlerKind::WriteStdin => { + builder.register_handler(Arc::new(WriteStdinHandler)); } } } if let Some(deferred_mcp_tools) = deferred_mcp_tools.as_ref() { - for (name, _) in deferred_mcp_tools.iter().filter(|(name, _)| { + for (_, tool) in deferred_mcp_tools.iter().filter(|(name, _)| { !mcp_tools .as_ref() .is_some_and(|tools| tools.contains_key(*name)) }) { - builder.register_handler(name.clone(), mcp_handler.clone()); + builder.register_handler(Arc::new(McpHandler::new(tool.canonical_tool_name()))); } } @@ -326,7 +339,7 @@ pub(crate) fn build_specs_with_discoverable_tools( }; builder.push_spec(spec); } - builder.register_handler(unavailable_tool, unavailable_tool_handler.clone()); + builder.register_handler(Arc::new(UnavailableToolHandler::new(unavailable_tool))); } builder } diff --git a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs index 5f11816553..99d90c3451 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs @@ -26,12 +26,17 @@ use crate::tools::registry::ToolKind; use crate::tools::registry::ToolRegistry; use crate::turn_diff_tracker::TurnDiffTracker; -#[derive(Default)] -struct TestHandler; +struct TestHandler { + tool_name: codex_tools::ToolName, +} impl ToolHandler for TestHandler { type Output = FunctionToolOutput; + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + fn kind(&self) -> ToolKind { ToolKind::Function } @@ -53,10 +58,9 @@ async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> a "await tools.test_tool({})", ); - let registry = ToolRegistry::with_handler_for_test( - codex_tools::ToolName::plain("test_tool"), - Arc::new(TestHandler), - ); + let registry = ToolRegistry::with_handler_for_test(Arc::new(TestHandler { + tool_name: codex_tools::ToolName::plain("test_tool"), + })); let session = Arc::new(session); let turn = Arc::new(turn); @@ -165,10 +169,9 @@ async fn dispatch_lifecycle_trace_records_incompatible_payload_failures() -> any let (mut session, turn) = make_session_and_context().await; attach_test_trace(&mut session, &turn, temp.path())?; - let registry = ToolRegistry::with_handler_for_test( - codex_tools::ToolName::plain("test_tool"), - Arc::new(TestHandler), - ); + let registry = ToolRegistry::with_handler_for_test(Arc::new(TestHandler { + tool_name: codex_tools::ToolName::plain("test_tool"), + })); let session = Arc::new(session); let turn = Arc::new(turn); @@ -200,10 +203,7 @@ async fn missing_code_mode_wait_traces_only_the_wait_tool_call() -> anyhow::Resu let (mut session, turn) = make_session_and_context().await; attach_test_trace(&mut session, &turn, temp.path())?; - let registry = ToolRegistry::with_handler_for_test( - codex_tools::ToolName::plain(WAIT_TOOL_NAME), - Arc::new(CodeModeWaitHandler), - ); + let registry = ToolRegistry::with_handler_for_test(Arc::new(CodeModeWaitHandler)); let session = Arc::new(session); let turn = Arc::new(turn); diff --git a/codex-rs/linux-sandbox/BUILD.bazel b/codex-rs/linux-sandbox/BUILD.bazel index 87ca8ba066..aeef302ab3 100644 --- a/codex-rs/linux-sandbox/BUILD.bazel +++ b/codex-rs/linux-sandbox/BUILD.bazel @@ -1,36 +1,6 @@ -load("@rules_cc//cc:defs.bzl", "cc_library") load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "linux-sandbox", crate_name = "codex_linux_sandbox", - # Bazel wires vendored bubblewrap + libcap via :vendored-bwrap-ffi below - # and sets vendored_bwrap_available explicitly, so we skip Cargo's - # build.rs in Bazel builds. - build_script_enabled = False, - deps_extra = select({ - "@platforms//os:linux": [":vendored-bwrap-ffi"], - "//conditions:default": [], - }), - rustc_flags_extra = select({ - "@platforms//os:linux": ["--cfg=vendored_bwrap_available"], - "//conditions:default": [], - }), -) - -cc_library( - name = "vendored-bwrap-ffi", - srcs = ["//codex-rs/vendor:bubblewrap_c_sources"], - hdrs = [ - "config.h", - "//codex-rs/vendor:bubblewrap_headers", - ], - copts = [ - "-D_GNU_SOURCE", - "-Dmain=bwrap_main", - ], - includes = ["."], - deps = ["@libcap//:libcap"], - target_compatible_with = ["@platforms//os:linux"], - visibility = ["//visibility:private"], ) diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 05967661e2..aaacee27fa 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -27,6 +27,7 @@ libc = { workspace = true } seccompiler = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } url = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] @@ -40,7 +41,3 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } - -[build-dependencies] -cc = "1" -pkg-config = "0.3" diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 5745f4816c..1b4c1e5aa7 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -12,10 +12,10 @@ outside the current working directory whenever it is available. If `bwrap` is present but too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a no-`--argv0` compatibility path for the inner re-exec. If `bwrap` is missing, -the helper falls back to the vendored bubblewrap path compiled into this -binary. +the helper falls back to the bundled `codex-resources/bwrap` binary shipped +with Codex. Codex also surfaces a startup warning when `bwrap` is missing so users know it -is falling back to the vendored helper. Codex surfaces the same startup warning +is falling back to the bundled helper. Codex surfaces the same startup warning path when bubblewrap cannot create user namespaces. WSL2 follows the normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing because it cannot create the required user namespaces, so Codex rejects sandboxed shell @@ -28,8 +28,8 @@ commands that would enter the bubblewrap path. helper uses it. - If `bwrap` is present but too old to support `--argv0`, the helper uses a no-`--argv0` compatibility path for the inner re-exec. -- If `bwrap` is missing, the helper falls back to the vendored bubblewrap - path. +- If `bwrap` is missing, the helper falls back to the bundled + `codex-resources/bwrap` path. - If `bwrap` is missing, Codex also surfaces a startup warning instead of printing directly from the sandbox helper. - If bubblewrap cannot create user namespaces, Codex surfaces a startup warning diff --git a/codex-rs/linux-sandbox/build.rs b/codex-rs/linux-sandbox/build.rs index a2b4ca86ca..968cfc7e67 100644 --- a/codex-rs/linux-sandbox/build.rs +++ b/codex-rs/linux-sandbox/build.rs @@ -1,111 +1,3 @@ -use std::env; -use std::path::Path; -use std::path::PathBuf; - fn main() { - // Tell rustc/clippy that this is an expected cfg value. - println!("cargo:rustc-check-cfg=cfg(vendored_bwrap_available)"); - println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); - println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR"); - println!("cargo:rerun-if-env-changed=CODEX_SKIP_VENDORED_BWRAP"); - - // Rebuild if the vendored bwrap sources change. - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); - let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("bubblewrap.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("bind-mount.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("network.c").display() - ); - println!( - "cargo:rerun-if-changed={}", - vendor_dir.join("utils.c").display() - ); - - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target_os != "linux" || env::var_os("CODEX_SKIP_VENDORED_BWRAP").is_some() { - return; - } - - if let Err(err) = try_build_vendored_bwrap() { - panic!("failed to compile vendored bubblewrap for Linux target: {err}"); - } -} - -fn try_build_vendored_bwrap() -> Result<(), String> { - let manifest_dir = - PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); - let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); - let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; - let libcap = pkg_config::Config::new() - .probe("libcap") - .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; - - let config_h = out_dir.join("config.h"); - std::fs::write( - &config_h, - r#"#pragma once -#define PACKAGE_STRING "bubblewrap built at codex build-time" -"#, - ) - .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; - - let mut build = cc::Build::new(); - build - .file(src_dir.join("bubblewrap.c")) - .file(src_dir.join("bind-mount.c")) - .file(src_dir.join("network.c")) - .file(src_dir.join("utils.c")) - .include(&out_dir) - .include(&src_dir) - .define("_GNU_SOURCE", None) - // Rename `main` so we can call it via FFI. - .define("main", Some("bwrap_main")); - for include_path in libcap.include_paths { - // Use -idirafter so target sysroot headers win (musl cross builds), - // while still allowing libcap headers from the host toolchain. - build.flag(format!("-idirafter{}", include_path.display())); - } - - build.compile("build_time_bwrap"); - println!("cargo:rustc-cfg=vendored_bwrap_available"); - Ok(()) -} - -/// Resolve the bubblewrap source directory used for build-time compilation. -/// -/// Priority: -/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. -/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. -fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { - if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { - let src_dir = PathBuf::from(path); - if src_dir.exists() { - return Ok(src_dir); - } - return Err(format!( - "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", - src_dir.display() - )); - } - - let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); - if vendor_dir.exists() { - return Ok(vendor_dir); - } - - Err(format!( - "expected vendored bubblewrap at {}, but it was not found.\n\ -Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", - vendor_dir.display() - )) + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SHA256"); } diff --git a/codex-rs/linux-sandbox/config.h b/codex-rs/linux-sandbox/config.h deleted file mode 100644 index f08aa6fcee..0000000000 --- a/codex-rs/linux-sandbox/config.h +++ /dev/null @@ -1,3 +0,0 @@ -#pragma once - -#define PACKAGE_STRING "bubblewrap built at codex build-time" diff --git a/codex-rs/linux-sandbox/src/launcher.rs b/codex-rs/linux-sandbox/src/launcher.rs index 577ef52405..65f13b1b72 100644 --- a/codex-rs/linux-sandbox/src/launcher.rs +++ b/codex-rs/linux-sandbox/src/launcher.rs @@ -1,20 +1,28 @@ use std::ffi::CString; use std::fs::File; +use std::io::Read; use std::os::fd::AsRawFd; use std::os::raw::c_char; use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::PermissionsExt; use std::path::Path; +use std::path::PathBuf; use std::process::Command; use std::sync::OnceLock; -use crate::vendored_bwrap::exec_vendored_bwrap; use codex_sandboxing::find_system_bwrap_in_path; use codex_utils_absolute_path::AbsolutePathBuf; +use sha2::Digest as _; +use sha2::Sha256; + +const SHA256_HEX_LEN: usize = 64; +const NULL_SHA256_DIGEST: [u8; 32] = [0; 32]; #[derive(Debug, Clone, PartialEq, Eq)] enum BubblewrapLauncher { System(SystemBwrapLauncher), - Vendored, + Bundled(BundledBwrapLauncher), + Unavailable, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,6 +31,11 @@ struct SystemBwrapLauncher { supports_argv0: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct BundledBwrapLauncher { + program: AbsolutePathBuf, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct SystemBwrapCapabilities { supports_argv0: bool, @@ -34,30 +47,46 @@ pub(crate) fn exec_bwrap(argv: Vec, preserved_files: Vec) -> ! { BubblewrapLauncher::System(launcher) => { exec_system_bwrap(&launcher.program, argv, preserved_files) } - BubblewrapLauncher::Vendored => exec_vendored_bwrap(argv, preserved_files), + BubblewrapLauncher::Bundled(launcher) => { + exec_bundled_bwrap(&launcher.program, argv, preserved_files) + } + BubblewrapLauncher::Unavailable => { + panic!( + "bubblewrap is unavailable: no system bwrap was found on PATH and no bundled \ + codex-resources/bwrap binary was found next to the Codex executable" + ) + } } } fn preferred_bwrap_launcher() -> BubblewrapLauncher { static LAUNCHER: OnceLock = OnceLock::new(); LAUNCHER - .get_or_init(|| match find_system_bwrap_in_path() { - Some(path) => preferred_bwrap_launcher_for_path(&path), - None => BubblewrapLauncher::Vendored, + .get_or_init(|| { + if let Some(path) = find_system_bwrap_in_path() + && let Some(launcher) = system_bwrap_launcher_for_path(&path) + { + return BubblewrapLauncher::System(launcher); + } + + match bundled_bwrap_launcher() { + Some(launcher) => BubblewrapLauncher::Bundled(launcher), + None => BubblewrapLauncher::Unavailable, + } }) .clone() } -fn preferred_bwrap_launcher_for_path(system_bwrap_path: &Path) -> BubblewrapLauncher { - preferred_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities) +fn system_bwrap_launcher_for_path(system_bwrap_path: &Path) -> Option { + system_bwrap_launcher_for_path_with_probe(system_bwrap_path, system_bwrap_capabilities) } -fn preferred_bwrap_launcher_for_path_with_probe( +fn system_bwrap_launcher_for_path_with_probe( system_bwrap_path: &Path, system_bwrap_capabilities: impl FnOnce(&Path) -> Option, -) -> BubblewrapLauncher { +) -> Option { if !system_bwrap_path.is_file() { - return BubblewrapLauncher::Vendored; + return None; } let Some(SystemBwrapCapabilities { @@ -65,7 +94,7 @@ fn preferred_bwrap_launcher_for_path_with_probe( supports_perms: true, }) = system_bwrap_capabilities(system_bwrap_path) else { - return BubblewrapLauncher::Vendored; + return None; }; let system_bwrap_path = match AbsolutePathBuf::from_absolute_path(system_bwrap_path) { Ok(path) => path, @@ -74,7 +103,7 @@ fn preferred_bwrap_launcher_for_path_with_probe( system_bwrap_path.display() ), }; - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: system_bwrap_path, supports_argv0, }) @@ -83,10 +112,50 @@ fn preferred_bwrap_launcher_for_path_with_probe( pub(crate) fn preferred_bwrap_supports_argv0() -> bool { match preferred_bwrap_launcher() { BubblewrapLauncher::System(launcher) => launcher.supports_argv0, - BubblewrapLauncher::Vendored => true, + BubblewrapLauncher::Bundled(_) | BubblewrapLauncher::Unavailable => true, } } +fn bundled_bwrap_launcher() -> Option { + let current_exe = std::env::current_exe().ok()?; + find_bundled_bwrap_for_exe(¤t_exe).map(|program| BundledBwrapLauncher { program }) +} + +fn find_bundled_bwrap_for_exe(exe: &Path) -> Option { + bundled_bwrap_candidates_for_exe(exe) + .into_iter() + .find(|candidate| is_executable_file(candidate)) + .map(|path| { + AbsolutePathBuf::from_absolute_path(&path).unwrap_or_else(|err| { + panic!( + "failed to normalize bundled bubblewrap path {}: {err}", + path.display() + ) + }) + }) +} + +fn bundled_bwrap_candidates_for_exe(exe: &Path) -> Vec { + let Some(exe_dir) = exe.parent() else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + candidates.push(exe_dir.join("codex-resources").join("bwrap")); + if let Some(package_target_dir) = exe_dir.parent() { + candidates.push(package_target_dir.join("codex-resources").join("bwrap")); + } + candidates.push(exe_dir.join("bwrap")); + candidates +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { + return false; + }; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 +} + fn system_bwrap_capabilities(system_bwrap_path: &Path) -> Option { // bubblewrap added `--argv0` in v0.9.0: // https://github.com/containers/bubblewrap/releases/tag/v0.9.0 @@ -117,7 +186,7 @@ fn exec_system_bwrap( let program = CString::new(program.as_path().as_os_str().as_bytes()) .unwrap_or_else(|err| panic!("invalid system bubblewrap path: {err}")); let cstrings = argv_to_cstrings(&argv); - let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect(); + let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(CString::as_ptr).collect(); argv_ptrs.push(std::ptr::null()); // SAFETY: `program` and every entry in `argv_ptrs` are valid C strings for @@ -129,6 +198,124 @@ fn exec_system_bwrap( panic!("failed to exec system bubblewrap {program_path}: {err}"); } +fn exec_bundled_bwrap( + program: &AbsolutePathBuf, + argv: Vec, + preserved_files: Vec, +) -> ! { + let bwrap_file = File::open(program.as_path()).unwrap_or_else(|err| { + panic!( + "failed to open bundled bubblewrap {}: {err}", + program.as_path().display() + ) + }); + verify_bundled_bwrap_digest( + &bwrap_file, + expected_bundled_bwrap_sha256(), + program.as_path(), + ) + .unwrap_or_else(|err| panic!("{err}")); + + make_files_inheritable(&preserved_files); + + let fd_path = format!("/proc/self/fd/{}", bwrap_file.as_raw_fd()); + let program_cstring = CString::new(fd_path.as_str()) + .unwrap_or_else(|err| panic!("invalid bundled bubblewrap fd path: {err}")); + let cstrings = argv_to_cstrings(&argv); + let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(CString::as_ptr).collect(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: `program_cstring` and every entry in `argv_ptrs` are valid C + // strings for the duration of the call. On success `execv` does not return. + unsafe { + libc::execv(program_cstring.as_ptr(), argv_ptrs.as_ptr()); + } + let err = std::io::Error::last_os_error(); + panic!( + "failed to exec bundled bubblewrap {} via {fd_path}: {err}", + program.as_path().display() + ); +} + +fn expected_bundled_bwrap_sha256() -> Option<[u8; 32]> { + static EXPECTED: OnceLock> = OnceLock::new(); + *EXPECTED.get_or_init(|| { + let Some(raw_digest) = option_env!("CODEX_BWRAP_SHA256") else { + return None; + }; + let digest = parse_sha256_hex(raw_digest) + .unwrap_or_else(|err| panic!("invalid CODEX_BWRAP_SHA256 value: {err}")); + (digest != NULL_SHA256_DIGEST).then_some(digest) + }) +} + +fn verify_bundled_bwrap_digest( + file: &File, + expected: Option<[u8; 32]>, + path: &Path, +) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + + let mut file = file + .try_clone() + .map_err(|err| format!("failed to clone bundled bubblewrap fd: {err}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file.read(&mut buffer).map_err(|err| { + format!( + "failed to read bundled bubblewrap {} for digest verification: {err}", + path.display() + ) + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + let actual: [u8; 32] = hasher.finalize().into(); + if actual == expected { + return Ok(()); + } + + Err(format!( + "bundled bubblewrap digest mismatch for {}: expected sha256:{}, got sha256:{}", + path.display(), + bytes_to_hex(&expected), + bytes_to_hex(&actual), + )) +} + +fn parse_sha256_hex(raw: &str) -> Result<[u8; 32], String> { + if raw.len() != SHA256_HEX_LEN { + return Err(format!( + "expected {SHA256_HEX_LEN} hex characters, got {}", + raw.len() + )); + } + + let mut digest = [0_u8; 32]; + for (index, byte) in digest.iter_mut().enumerate() { + let start = index * 2; + *byte = u8::from_str_radix(&raw[start..start + 2], 16) + .map_err(|err| format!("invalid hex byte at offset {start}: {err}"))?; + } + Ok(digest) +} + +fn bytes_to_hex(bytes: &[u8; 32]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut hex = String::with_capacity(SHA256_HEX_LEN); + for byte in bytes { + hex.push(HEX[(byte >> 4) as usize] as char); + hex.push(HEX[(byte & 0x0f) as usize] as char); + } + hex +} + fn argv_to_cstrings(argv: &[String]) -> Vec { let mut cstrings: Vec = Vec::with_capacity(argv.len()); for arg in argv { @@ -170,7 +357,10 @@ fn clear_cloexec(fd: libc::c_int) { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::fs; + use std::os::unix::fs::PermissionsExt; use tempfile::NamedTempFile; + use tempfile::tempdir; #[test] fn prefers_system_bwrap_when_help_lists_argv0() { @@ -179,13 +369,13 @@ mod tests { let expected = AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { Some(SystemBwrapCapabilities { supports_argv0: true, supports_perms: true, }) }), - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: expected, supports_argv0: true, }) @@ -198,13 +388,13 @@ mod tests { let fake_bwrap_path = fake_bwrap.path(); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap_path, |_| { Some(SystemBwrapCapabilities { supports_argv0: false, supports_perms: true, }) }), - BubblewrapLauncher::System(SystemBwrapLauncher { + Some(SystemBwrapLauncher { program: AbsolutePathBuf::from_absolute_path(fake_bwrap_path).expect("absolute"), supports_argv0: false, }) @@ -212,28 +402,108 @@ mod tests { } #[test] - fn falls_back_to_vendored_when_system_bwrap_lacks_perms() { + fn ignores_system_bwrap_when_system_bwrap_lacks_perms() { let fake_bwrap = NamedTempFile::new().expect("temp file"); assert_eq!( - preferred_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| { + system_bwrap_launcher_for_path_with_probe(fake_bwrap.path(), |_| { Some(SystemBwrapCapabilities { supports_argv0: false, supports_perms: false, }) }), - BubblewrapLauncher::Vendored + None ); } #[test] - fn falls_back_to_vendored_when_system_bwrap_is_missing() { + fn ignores_system_bwrap_when_system_bwrap_is_missing() { assert_eq!( - preferred_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), - BubblewrapLauncher::Vendored + system_bwrap_launcher_for_path(Path::new("/definitely/not/a/bwrap")), + None ); } + #[test] + fn finds_standalone_bundled_bwrap_next_to_exe_resources() { + let temp_dir = tempdir().expect("temp dir"); + let exe = temp_dir.path().join("codex"); + let expected_bwrap = temp_dir.path().join("codex-resources").join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_bundled_bwrap_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn finds_npm_bundled_bwrap_next_to_target_vendor_dir() { + let temp_dir = tempdir().expect("temp dir"); + let target_dir = temp_dir.path().join("vendor/x86_64-unknown-linux-musl"); + let exe = target_dir.join("codex").join("codex"); + let expected_bwrap = target_dir.join("codex-resources").join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_bundled_bwrap_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn finds_adjacent_dev_bwrap() { + let temp_dir = tempdir().expect("temp dir"); + let exe = temp_dir.path().join("codex"); + let expected_bwrap = temp_dir.path().join("bwrap"); + write_executable(&exe); + write_executable(&expected_bwrap); + + assert_eq!( + find_bundled_bwrap_for_exe(&exe), + Some(AbsolutePathBuf::from_absolute_path(&expected_bwrap).expect("absolute")) + ); + } + + #[test] + fn bundled_digest_verification_skips_missing_expected_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + + verify_bundled_bwrap_digest(file.as_file(), /*expected*/ None, file.path()) + .expect("missing digest should skip verification"); + } + + #[test] + fn bundled_digest_verification_accepts_matching_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + let expected: [u8; 32] = Sha256::digest(b"contents").into(); + + verify_bundled_bwrap_digest(file.as_file(), Some(expected), file.path()) + .expect("matching digest should verify"); + } + + #[test] + fn bundled_digest_verification_rejects_mismatched_digest() { + let file = NamedTempFile::new().expect("temp file"); + fs::write(file.path(), b"contents").expect("write file"); + + let err = verify_bundled_bwrap_digest(file.as_file(), Some([0xab; 32]), file.path()) + .expect_err("mismatched digest should fail"); + assert!(err.contains("bundled bubblewrap digest mismatch")); + } + + #[test] + fn parses_sha256_hex_digest() { + assert_eq!(parse_sha256_hex(&"ab".repeat(32)), Ok([0xab; 32])); + assert_eq!(parse_sha256_hex(&"00".repeat(32)), Ok(NULL_SHA256_DIGEST)); + assert!(parse_sha256_hex("ab").is_err()); + assert!(parse_sha256_hex(&format!("{}xx", "00".repeat(31))).is_err()); + } + #[test] fn preserved_files_are_made_inheritable_for_system_exec() { let file = NamedTempFile::new().expect("temp file"); @@ -244,6 +514,15 @@ mod tests { assert_eq!(fd_flags(file.as_file().as_raw_fd()) & libc::FD_CLOEXEC, 0); } + fn write_executable(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dir"); + } + fs::write(path, b"").expect("write executable"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("set executable permissions"); + } + fn set_cloexec(fd: libc::c_int) { let flags = fd_flags(fd); // SAFETY: `fd` is valid for the duration of the test. diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 900287c99d..29ca45812a 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -13,8 +13,6 @@ mod launcher; mod linux_run_main; #[cfg(target_os = "linux")] mod proxy_routing; -#[cfg(target_os = "linux")] -mod vendored_bwrap; #[cfg(target_os = "linux")] pub fn run_main() -> ! { diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index c88a28b324..346b1f14c0 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -25,6 +25,7 @@ use crate::launcher::exec_bwrap; use crate::launcher::preferred_bwrap_supports_argv0; use crate::proxy_routing::activate_proxy_routes_in_netns; use crate::proxy_routing::prepare_host_proxy_route_spec; +use codex_protocol::error::Result as CodexResult; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::NetworkSandboxPolicy; @@ -333,6 +334,7 @@ fn run_bwrap_with_proc_fallback( file_system_sandbox_policy, network_mode, ) + .unwrap_or_else(|err| exit_with_bwrap_build_error(err)) { // Keep the retry silent so sandbox-internal diagnostics do not leak into the // child process stderr stream. @@ -350,7 +352,8 @@ fn run_bwrap_with_proc_fallback( sandbox_policy_cwd, command_cwd, options, - ); + ) + .unwrap_or_else(|err| exit_with_bwrap_build_error(err)); apply_inner_command_argv0(&mut bwrap_args.args); run_or_exec_bwrap(bwrap_args); } @@ -374,24 +377,28 @@ fn build_bwrap_argv( sandbox_policy_cwd: &Path, command_cwd: &Path, options: BwrapOptions, -) -> crate::bwrap::BwrapArgs { +) -> CodexResult { let bwrap_args = create_bwrap_command_args( inner, file_system_sandbox_policy, sandbox_policy_cwd, command_cwd, options, - ) - .unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}")); + )?; let mut argv = vec!["bwrap".to_string()]; argv.extend(bwrap_args.args); - crate::bwrap::BwrapArgs { + Ok(crate::bwrap::BwrapArgs { args: argv, preserved_files: bwrap_args.preserved_files, synthetic_mount_targets: bwrap_args.synthetic_mount_targets, protected_create_targets: bwrap_args.protected_create_targets, - } + }) +} + +fn exit_with_bwrap_build_error(err: codex_protocol::error::CodexErr) -> ! { + eprintln!("error building bubblewrap command: {err}"); + std::process::exit(1); } fn apply_inner_command_argv0(argv: &mut Vec) { @@ -439,15 +446,15 @@ fn preflight_proc_mount_support( command_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, -) -> bool { +) -> CodexResult { let preflight_argv = build_preflight_bwrap_argv( sandbox_policy_cwd, command_cwd, file_system_sandbox_policy, network_mode, - ); + )?; let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); - !is_proc_mount_failure(stderr.as_str()) + Ok(!is_proc_mount_failure(stderr.as_str())) } fn build_preflight_bwrap_argv( @@ -455,7 +462,7 @@ fn build_preflight_bwrap_argv( command_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, -) -> crate::bwrap::BwrapArgs { +) -> CodexResult { let preflight_command = vec![resolve_true_command()]; build_bwrap_argv( preflight_command, @@ -1235,7 +1242,10 @@ fn synthetic_mount_marker_dir(path: &Path) -> PathBuf { } fn synthetic_mount_registry_root() -> PathBuf { - std::env::temp_dir().join("codex-bwrap-synthetic-mount-targets") + let effective_uid = unsafe { libc::geteuid() }; + std::env::temp_dir().join(format!( + "codex-bwrap-synthetic-mount-targets-{effective_uid}" + )) } fn hash_path(path: &Path) -> u64 { diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 228cea6e5d..4441af7809 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -61,6 +61,7 @@ fn inserts_bwrap_argv0_before_command_separator() { ..Default::default() }, ) + .expect("build bwrap argv") .args; apply_inner_command_argv0_for_launcher( &mut argv, @@ -104,6 +105,7 @@ fn rewrites_inner_command_path_when_bwrap_lacks_argv0() { ..Default::default() }, ) + .expect("build bwrap argv") .args; apply_inner_command_argv0_for_launcher( &mut argv, @@ -172,6 +174,7 @@ fn inserts_unshare_net_when_network_isolation_requested() { ..Default::default() }, ) + .expect("build bwrap argv") .args; assert!(argv.contains(&"--unshare-net".to_string())); } @@ -190,6 +193,7 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() { ..Default::default() }, ) + .expect("build bwrap argv") .args; assert!(argv.contains(&"--unshare-net".to_string())); } @@ -265,6 +269,7 @@ fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() { &FileSystemSandboxPolicy::unrestricted(), mode, ) + .expect("build preflight argv") .args; assert!(argv.iter().any(|arg| arg == "--")); } @@ -297,6 +302,17 @@ fn cleanup_synthetic_mount_targets_removes_only_empty_mount_targets() { assert!(!missing_file.exists()); } +#[test] +fn synthetic_mount_registry_root_is_unique_to_effective_user() { + let effective_uid = unsafe { libc::geteuid() }; + assert_eq!( + synthetic_mount_registry_root(), + std::env::temp_dir().join(format!( + "codex-bwrap-synthetic-mount-targets-{effective_uid}" + )) + ); +} + #[test] fn cleanup_synthetic_mount_targets_waits_for_other_active_registrations() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); diff --git a/codex-rs/linux-sandbox/src/vendored_bwrap.rs b/codex-rs/linux-sandbox/src/vendored_bwrap.rs deleted file mode 100644 index a2da14db05..0000000000 --- a/codex-rs/linux-sandbox/src/vendored_bwrap.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Build-time bubblewrap entrypoint. -//! -//! On Linux targets, the build script compiles bubblewrap's C sources and -//! exposes a `bwrap_main` symbol that we can call via FFI. - -#[cfg(vendored_bwrap_available)] -mod imp { - use std::ffi::CString; - use std::fs::File; - use std::os::raw::c_char; - - unsafe extern "C" { - fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; - } - - fn argv_to_cstrings(argv: &[String]) -> Vec { - let mut cstrings: Vec = Vec::with_capacity(argv.len()); - for arg in argv { - match CString::new(arg.as_str()) { - Ok(value) => cstrings.push(value), - Err(err) => panic!("failed to convert argv to CString: {err}"), - } - } - cstrings - } - - /// Run the build-time bubblewrap `main` function and return its exit code. - /// - /// On success, bubblewrap will `execve` into the target program and this - /// function will never return. A return value therefore implies failure. - pub(crate) fn run_vendored_bwrap_main( - argv: &[String], - _preserved_files: &[File], - ) -> libc::c_int { - let cstrings = argv_to_cstrings(argv); - - let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect(); - argv_ptrs.push(std::ptr::null()); - - // SAFETY: We provide a null-terminated argv vector whose pointers - // remain valid for the duration of the call. - unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) } - } - - /// Execute the build-time bubblewrap `main` function with the given argv. - pub(crate) fn exec_vendored_bwrap(argv: Vec, preserved_files: Vec) -> ! { - let exit_code = run_vendored_bwrap_main(&argv, &preserved_files); - std::process::exit(exit_code); - } -} - -#[cfg(not(vendored_bwrap_available))] -mod imp { - use std::fs::File; - - /// Panics with a clear error when the build-time bwrap path is not enabled. - pub(crate) fn run_vendored_bwrap_main( - _argv: &[String], - _preserved_files: &[File], - ) -> libc::c_int { - panic!( - r#"build-time bubblewrap is not available in this build. -codex-linux-sandbox should always compile vendored bubblewrap on Linux targets. -Notes: -- ensure the target OS is Linux -- libcap headers must be available via pkg-config -- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"# - ); - } - - /// Panics with a clear error when the build-time bwrap path is not enabled. - pub(crate) fn exec_vendored_bwrap(_argv: Vec, _preserved_files: Vec) -> ! { - let _ = run_vendored_bwrap_main(&[], &[]); - unreachable!("run_vendored_bwrap_main should always panic in this configuration") - } -} - -pub(crate) use imp::exec_vendored_bwrap; diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index efbcd0b486..87e4ce68ae 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -40,7 +40,7 @@ const NETWORK_TIMEOUT_MS: u64 = 10_000; #[cfg(target_arch = "aarch64")] const NETWORK_TIMEOUT_MS: u64 = 10_000; -const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build."; +const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; fn create_env_from_core_vars() -> HashMap { let policy = ShellEnvironmentPolicy::default(); @@ -587,6 +587,59 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() { assert_ne!(codex_output.exit_code, 0); } +#[tokio::test] +async fn sandbox_reports_codex_symlink_build_failure_without_panicking() { + if should_skip_bwrap_tests().await { + eprintln!("skipping bwrap test: bwrap sandbox prerequisites are unavailable"); + return; + } + + use std::os::unix::fs::symlink; + + let tmpdir = tempfile::tempdir().expect("tempdir"); + let decoy = tmpdir.path().join("decoy-codex"); + std::fs::create_dir_all(&decoy).expect("create decoy dir"); + + let dot_codex = tmpdir.path().join(".codex"); + symlink(&decoy, &dot_codex).expect("create .codex symlink"); + + let output = match run_cmd_result_with_writable_roots( + &["bash", "-lc", "true"], + &[tmpdir.path().to_path_buf()], + LONG_TIMEOUT_MS, + /*use_legacy_landlock*/ false, + /*network_access*/ true, + ) + .await + { + Err(CodexErr::Sandbox(SandboxErr::Denied { output, .. })) => *output, + result => panic!(".codex symlink build failure should deny: {result:?}"), + }; + + assert_eq!(output.exit_code, 1); + assert!( + output + .stderr + .text + .contains("error building bubblewrap command:"), + "stderr: {}", + output.stderr.text + ); + assert!( + output + .stderr + .text + .contains("cannot enforce sandbox read-only path"), + "stderr: {}", + output.stderr.text + ); + assert!( + !output.stderr.text.contains("panicked at"), + "stderr: {}", + output.stderr.text + ); +} + #[tokio::test] async fn sandbox_keeps_parent_repo_discovery_while_blocking_child_metadata() { if should_skip_bwrap_tests().await { diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index 932b7981d3..d1aa6856c4 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -15,7 +15,7 @@ use std::process::Stdio; use std::time::Duration; use tokio::process::Command; -const BWRAP_UNAVAILABLE_ERR: &str = "build-time bubblewrap is not available in this build."; +const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable: no system bwrap was found"; const NETWORK_TIMEOUT_MS: u64 = 4_000; const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[ "loopback: Failed RTM_NEWADDR", @@ -82,7 +82,7 @@ fn is_managed_proxy_permission_error(stderr: &str) -> bool { async fn managed_proxy_skip_reason() -> Option { if should_skip_bwrap_tests().await { - return Some("vendored bwrap was not built in this environment".to_string()); + return Some("bubblewrap is unavailable in this environment".to_string()); } let mut env = create_env_from_core_vars(); diff --git a/codex-rs/sandboxing/src/bwrap.rs b/codex-rs/sandboxing/src/bwrap.rs index 840bb3e62d..e0eee177fb 100644 --- a/codex-rs/sandboxing/src/bwrap.rs +++ b/codex-rs/sandboxing/src/bwrap.rs @@ -18,7 +18,7 @@ const MISSING_BWRAP_WARNING: &str = concat!( "Install bubblewrap with your OS package manager. ", "See the sandbox prerequisites: ", "https://developers.openai.com/codex/concepts/sandboxing#prerequisites. ", - "Codex will use the vendored bubblewrap in the meantime.", + "Codex will use the bundled bubblewrap in the meantime.", ); const USER_NAMESPACE_WARNING: &str = "Codex's Linux sandbox uses bubblewrap and needs access to create user namespaces."; diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index f350bce2cd..9d84ece674 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -40,6 +40,7 @@ use codex_core_api::Permissions; use codex_core_api::ProjectConfig; use codex_core_api::RealtimeAudioConfig; use codex_core_api::RealtimeConfig; +use codex_core_api::SessionPickerViewMode; use codex_core_api::SessionSource; use codex_core_api::ShellEnvironmentPolicy; use codex_core_api::TerminalResizeReflowConfig; @@ -200,6 +201,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_raw_output_mode: false, terminal_resize_reflow: TerminalResizeReflowConfig::default(), tui_keymap: TuiKeymap::default(), + tui_session_picker_view: SessionPickerViewMode::Dense, tui_vim_mode_default: false, cwd, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 687f99fcda..c777bd4129 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -172,8 +172,8 @@ pub fn build_tool_registry_plan( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("exec_command", ToolHandlerKind::UnifiedExec); - plan.register_handler("write_stdin", ToolHandlerKind::UnifiedExec); + plan.register_handler("exec_command", ToolHandlerKind::ExecCommand); + plan.register_handler("write_stdin", ToolHandlerKind::WriteStdin); } ConfigShellToolType::Disabled => {} ConfigShellToolType::ShellCommand => { @@ -193,8 +193,8 @@ pub fn build_tool_registry_plan( && config.shell_type != ConfigShellToolType::Disabled { plan.register_handler("shell", ToolHandlerKind::Shell); - plan.register_handler("container.exec", ToolHandlerKind::Shell); - plan.register_handler("local_shell", ToolHandlerKind::Shell); + plan.register_handler("container.exec", ToolHandlerKind::ContainerExec); + plan.register_handler("local_shell", ToolHandlerKind::LocalShell); plan.register_handler("shell_command", ToolHandlerKind::ShellCommand); } @@ -214,9 +214,12 @@ pub fn build_tool_registry_plan( /*supports_parallel_tool_calls*/ true, config.code_mode_enabled, ); - plan.register_handler("list_mcp_resources", ToolHandlerKind::McpResource); - plan.register_handler("list_mcp_resource_templates", ToolHandlerKind::McpResource); - plan.register_handler("read_mcp_resource", ToolHandlerKind::McpResource); + plan.register_handler("list_mcp_resources", ToolHandlerKind::ListMcpResources); + plan.register_handler( + "list_mcp_resource_templates", + ToolHandlerKind::ListMcpResourceTemplates, + ); + plan.register_handler("read_mcp_resource", ToolHandlerKind::ReadMcpResource); } plan.push_spec( @@ -231,19 +234,19 @@ pub fn build_tool_registry_plan( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("get_goal", ToolHandlerKind::Goal); + plan.register_handler("get_goal", ToolHandlerKind::GetGoal); plan.push_spec( create_create_goal_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("create_goal", ToolHandlerKind::Goal); + plan.register_handler("create_goal", ToolHandlerKind::CreateGoal); plan.push_spec( create_update_goal_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("update_goal", ToolHandlerKind::Goal); + plan.register_handler("update_goal", ToolHandlerKind::UpdateGoal); } plan.push_spec( @@ -493,14 +496,17 @@ pub fn build_tool_registry_plan( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("spawn_agents_on_csv", ToolHandlerKind::AgentJobs); + plan.register_handler("spawn_agents_on_csv", ToolHandlerKind::SpawnAgentsOnCsv); if config.agent_jobs_worker_tools { plan.push_spec( create_report_agent_job_result_tool(), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - plan.register_handler("report_agent_job_result", ToolHandlerKind::AgentJobs); + plan.register_handler( + "report_agent_job_result", + ToolHandlerKind::ReportAgentJobResult, + ); } } diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index 2601942534..0212cb53d4 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -10,19 +10,26 @@ use std::collections::HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolHandlerKind { - AgentJobs, ApplyPatch, CloseAgentV1, CloseAgentV2, CodeModeExecute, CodeModeWait, + ContainerExec, + CreateGoal, DynamicTool, + ExecCommand, FollowupTaskV2, - Goal, + GetGoal, ListAgentsV2, + ListMcpResourceTemplates, + ListMcpResources, + LocalShell, Mcp, - McpResource, Plan, + ReadMcpResource, + ReportAgentJobResult, + RequestPluginInstall, RequestPermissions, RequestUserInput, ResumeAgentV1, @@ -30,15 +37,16 @@ pub enum ToolHandlerKind { SendMessageV2, Shell, ShellCommand, + SpawnAgentsOnCsv, SpawnAgentV1, SpawnAgentV2, TestSync, ToolSearch, - RequestPluginInstall, - UnifiedExec, + UpdateGoal, ViewImage, WaitAgentV1, WaitAgentV2, + WriteStdin, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 068084839b..37fcdb4251 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -77,7 +77,7 @@ impl App { return Ok(AppRunControl::Continue); } }; - match crate::resume_picker::run_resume_picker_with_app_server( + match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server( tui, &self.config, /*show_all*/ false, @@ -97,9 +97,13 @@ impl App { } } } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Fork(_) => {} + SessionSelection::Exit | SessionSelection::StartFresh => { + self.refresh_in_memory_config_from_disk_best_effort( + "closing the session picker", + ) + .await; + } + SessionSelection::Fork(_) => {} } // Leaving alt-screen may blank the inline viewport; force a redraw either way. diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 30169904d7..6d1278ea2d 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -328,16 +328,25 @@ impl ChatWidget { SlashCommand::Diff => { self.add_diff_in_progress(); let tx = self.app_event_tx.clone(); + let runner = self.workspace_command_runner.clone(); + let cwd = self + .current_cwd + .clone() + .unwrap_or_else(|| self.config.cwd.to_path_buf()); 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() + let text = match runner { + Some(runner) => match get_git_diff(runner.as_ref(), &cwd).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}"), + Err(e) => format!("Failed to compute diff: {e}"), + }, + None => "Failed to compute diff: workspace command runner unavailable" + .to_string(), }; tx.send(AppEvent::DiffResult(text)); }); diff --git a/codex-rs/tui/src/get_git_diff.rs b/codex-rs/tui/src/get_git_diff.rs index 78ab53d92f..a7b4b668fb 100644 --- a/codex-rs/tui/src/get_git_diff.rs +++ b/codex-rs/tui/src/get_git_diff.rs @@ -5,25 +5,32 @@ //! 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; +use std::time::Duration; + +use crate::workspace_command::WorkspaceCommand; +use crate::workspace_command::WorkspaceCommandExecutor; +use crate::workspace_command::WorkspaceCommandOutput; + +const DIFF_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); /// 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)> { +pub(crate) async fn get_git_diff( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Result<(bool, String), String> { // First check if we are inside a Git repository. - if !inside_git_repo().await? { + if !inside_git_repo(runner, cwd).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"]), + run_git_capture_diff(runner, cwd, &["diff", "--color"]), + run_git_capture_stdout(runner, cwd, &["ls-files", "--others", "--exclude-standard"]), ); let tracked_diff = tracked_diff_res?; let untracked_output = untracked_output_res?; @@ -35,27 +42,15 @@ pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { 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(); + let null_path = null_device.to_str().unwrap_or("/dev/null"); 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(_) => {} - } + let args = ["diff", "--color", "--no-index", "--", null_path, file]; + let diff = run_git_capture_diff(runner, cwd, &args).await?; + untracked_diff.push_str(&diff); } Ok((true, format!("{tracked_diff}{untracked_diff}"))) @@ -63,57 +58,282 @@ pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { /// 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()) +async fn run_git_capture_stdout( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let output = run_git_command(runner, cwd, args).await?; + if output.success() { + Ok(output.stdout) } else { - Err(io::Error::other(format!( + Err(format!( "git {:?} failed with status {}", - args, output.status - ))) + args, output.exit_code + )) } } /// 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()) +async fn run_git_capture_diff( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let output = run_git_command(runner, cwd, args).await?; + if output.success() || output.exit_code == 1 { + Ok(output.stdout) } else { - Err(io::Error::other(format!( + Err(format!( "git {:?} failed with status {}", - args, output.status - ))) + args, output.exit_code + )) } } /// 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; +async fn inside_git_repo( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, +) -> Result { + let output = run_git_command(runner, cwd, &["rev-parse", "--is-inside-work-tree"]).await?; + Ok(output.success()) +} - 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), +async fn run_git_command( + runner: &dyn WorkspaceCommandExecutor, + cwd: &Path, + args: &[&str], +) -> Result { + let mut argv = Vec::with_capacity(args.len() + 1); + argv.push("git".to_string()); + argv.extend(args.iter().map(|arg| (*arg).to_string())); + runner + .run( + WorkspaceCommand::new(argv) + .cwd(cwd.to_path_buf()) + .timeout(DIFF_COMMAND_TIMEOUT) + .disable_output_cap(), + ) + .await + .map_err(|err| err.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace_command::WorkspaceCommandError; + use pretty_assertions::assert_eq; + use std::collections::VecDeque; + use std::future::Future; + use std::path::PathBuf; + use std::pin::Pin; + use std::sync::Mutex; + + #[tokio::test] + async fn get_git_diff_returns_not_git_for_non_git_cwd() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![response( + &["git", "rev-parse", "--is-inside-work-tree"], + /*exit_code*/ 128, + "", + )]); + + let result = get_git_diff(&runner, &cwd).await; + + assert_eq!(result, Ok((false, String::new()))); + assert_commands( + &runner.commands(), + &[&["git", "rev-parse", "--is-inside-work-tree"]], + &cwd, + ); + } + + #[tokio::test] + async fn get_git_diff_concatenates_tracked_and_untracked_diffs() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![ + response( + &["git", "rev-parse", "--is-inside-work-tree"], + /*exit_code*/ 0, + "true\n", + ), + response( + &["git", "diff", "--color"], + /*exit_code*/ 1, + "tracked\n", + ), + response( + &["git", "ls-files", "--others", "--exclude-standard"], + /*exit_code*/ 0, + "new.txt\n", + ), + response( + &[ + "git", + "diff", + "--color", + "--no-index", + "--", + null_device(), + "new.txt", + ], + /*exit_code*/ 1, + "untracked\n", + ), + ]); + + let result = get_git_diff(&runner, &cwd).await; + + assert_eq!(result, Ok((true, "tracked\nuntracked\n".to_string()))); + assert_commands( + &runner.commands(), + &[ + &["git", "rev-parse", "--is-inside-work-tree"], + &["git", "diff", "--color"], + &["git", "ls-files", "--others", "--exclude-standard"], + &[ + "git", + "diff", + "--color", + "--no-index", + "--", + null_device(), + "new.txt", + ], + ], + &cwd, + ); + } + + #[tokio::test] + async fn get_git_diff_accepts_diff_exit_code_one() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![ + response( + &["git", "rev-parse", "--is-inside-work-tree"], + /*exit_code*/ 0, + "true\n", + ), + response( + &["git", "diff", "--color"], + /*exit_code*/ 1, + "tracked\n", + ), + response( + &["git", "ls-files", "--others", "--exclude-standard"], + /*exit_code*/ 0, + "", + ), + ]); + + let result = get_git_diff(&runner, &cwd).await; + + assert_eq!(result, Ok((true, "tracked\n".to_string()))); + } + + #[tokio::test] + async fn get_git_diff_rejects_unexpected_git_diff_status() { + let cwd = PathBuf::from("/workspace"); + let runner = FakeRunner::new(vec![ + response( + &["git", "rev-parse", "--is-inside-work-tree"], + /*exit_code*/ 0, + "true\n", + ), + response(&["git", "diff", "--color"], /*exit_code*/ 2, ""), + response( + &["git", "ls-files", "--others", "--exclude-standard"], + /*exit_code*/ 0, + "", + ), + ]); + + let error = get_git_diff(&runner, &cwd) + .await + .expect_err("unexpected git diff status should fail"); + + assert!( + error.contains("git [\"diff\", \"--color\"] failed with status 2"), + "unexpected error: {error}", + ); + } + + fn response(argv: &[&str], exit_code: i32, stdout: &str) -> FakeResponse { + FakeResponse { + argv: argv.iter().map(|arg| (*arg).to_string()).collect(), + output: WorkspaceCommandOutput { + exit_code, + stdout: stdout.to_string(), + stderr: String::new(), + }, + } + } + + fn null_device() -> &'static str { + if cfg!(windows) { "NUL" } else { "/dev/null" } + } + + fn assert_commands(commands: &[WorkspaceCommand], expected: &[&[&str]], cwd: &Path) { + let actual: Vec> = commands + .iter() + .map(|command| command.argv.clone()) + .collect(); + let expected: Vec> = expected + .iter() + .map(|argv| argv.iter().map(|arg| (*arg).to_string()).collect()) + .collect(); + assert_eq!(actual, expected); + + for command in commands { + assert_eq!(command.cwd.as_deref(), Some(cwd)); + assert_eq!(command.timeout, DIFF_COMMAND_TIMEOUT); + assert!(command.disable_output_cap); + } + } + + struct FakeResponse { + argv: Vec, + output: WorkspaceCommandOutput, + } + + struct FakeRunner { + responses: Mutex>, + commands: Mutex>, + } + + impl FakeRunner { + fn new(responses: Vec) -> Self { + Self { + responses: Mutex::new(responses.into()), + commands: Mutex::new(Vec::new()), + } + } + + fn commands(&self) -> Vec { + self.commands.lock().expect("commands lock").clone() + } + } + + impl WorkspaceCommandExecutor for FakeRunner { + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + Box::pin(async move { + let mut responses = self.responses.lock().expect("responses lock"); + let response = responses.pop_front().expect("missing fake response"); + assert_eq!(command.argv, response.argv); + self.commands.lock().expect("commands lock").push(command); + Ok(response.output) + }) + } } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 55c190a66d..5622c59f65 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1430,6 +1430,11 @@ async fn run_ratatui_app( None => None, }; + let picker_cancelled_without_selection = matches!( + session_selection, + resume_picker::SessionSelection::StartFresh + ) && (cli.resume_picker || cli.fork_picker); + let mut config = match &session_selection { resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => { load_config_or_exit_with_fallback_cwd( @@ -1440,6 +1445,14 @@ async fn run_ratatui_app( ) .await } + resume_picker::SessionSelection::StartFresh if picker_cancelled_without_selection => { + load_config_or_exit( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + ) + .await + } _ => config, }; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index dc148f0059..16c3d19071 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1,24 +1,39 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +mod transcript; + use crate::app_server_session::AppServerSession; -use crate::diff_render::display_path_for; -use crate::key_hint; +use crate::color::blend; +use crate::color::is_light; +use crate::keymap::PagerKeymap; +use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; +use crate::legacy_core::config::edit::ConfigEditsBuilder; +use crate::markdown::append_markdown; +use crate::pager_overlay::Overlay; use crate::session_resume::resolve_session_thread_id; +use crate::status::format_directory_display; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_bg; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; +use codex_config::types::SessionPickerViewMode; use codex_protocol::ThreadId; use codex_utils_path as path_utils; use color_eyre::eyre::Result; @@ -29,17 +44,37 @@ use crossterm::event::KeyModifiers; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Styled as _; use ratatui::style::Stylize as _; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::widgets::Clear; +use ratatui::widgets::Widget; use tokio::sync::mpsc; use tokio_stream::StreamExt; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::warn; +use transcript::RawReasoningVisibility; +use transcript::TranscriptCells; +use transcript::load_session_transcript; use unicode_width::UnicodeWidthStr; const PAGE_SIZE: usize = 25; const LOAD_NEAR_THRESHOLD: usize = 5; +const SESSION_META_INDENT_WIDTH: usize = 2; +const SESSION_META_DATE_WIDTH: usize = 12; +const SESSION_META_FIELD_GAP_WIDTH: usize = 2; +const SESSION_META_MIN_CWD_WIDTH: usize = 30; +const SESSION_META_MAX_CWD_WIDTH: usize = 72; +const SESSION_META_BRANCH_ICON: &str = ""; +const SESSION_META_CWD_ICON: &str = "⌁"; +const FOOTER_COMPACT_BREAKPOINT: u16 = 120; +const FOOTER_HINT_LEFT_PADDING: usize = 1; +const FOOTER_HINT_GAP: usize = 3; +const PICKER_CHROME_HEIGHT: u16 = 8; +const PICKER_LIST_HORIZONTAL_INSET: u16 = 4; #[derive(Debug, Clone)] pub struct SessionTarget { @@ -70,6 +105,12 @@ pub enum SessionPickerAction { Fork, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SessionPickerLaunchContext { + Startup, + ExistingSession, +} + impl SessionPickerAction { fn title(self) -> &'static str { match self { @@ -99,24 +140,117 @@ struct PageLoadRequest { cursor: Option, request_token: usize, search_token: Option, + cwd_filter: Option, provider_filter: ProviderFilter, sort_key: ThreadSortKey, } +enum PickerLoadRequest { + Page(PageLoadRequest), + Preview { thread_id: ThreadId }, + Transcript { thread_id: ThreadId }, +} + #[derive(Clone)] enum ProviderFilter { Any, MatchDefault(String), } -type PageLoader = Arc; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionFilterMode { + Cwd, + All, +} +impl SessionFilterMode { + fn from_show_all(show_all: bool, filter_cwd: Option<&Path>) -> Self { + if show_all || filter_cwd.is_none() { + Self::All + } else { + Self::Cwd + } + } + + fn toggle(self, filter_cwd: Option<&Path>) -> Self { + match self { + Self::Cwd => Self::All, + Self::All if filter_cwd.is_some() => Self::Cwd, + Self::All => Self::All, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ToolbarControl { + Filter, + Sort, +} + +impl ToolbarControl { + fn previous(self) -> Self { + match self { + Self::Filter => Self::Sort, + Self::Sort => Self::Filter, + } + } + + fn next(self) -> Self { + match self { + Self::Filter => Self::Sort, + Self::Sort => Self::Filter, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionListDensity { + Comfortable, + Dense, +} + +impl SessionListDensity { + fn toggle(self) -> Self { + match self { + Self::Comfortable => Self::Dense, + Self::Dense => Self::Comfortable, + } + } +} + +impl From for SessionListDensity { + fn from(mode: SessionPickerViewMode) -> Self { + match mode { + SessionPickerViewMode::Comfortable => Self::Comfortable, + SessionPickerViewMode::Dense => Self::Dense, + } + } +} + +impl From for SessionPickerViewMode { + fn from(density: SessionListDensity) -> Self { + match density { + SessionListDensity::Comfortable => Self::Comfortable, + SessionListDensity::Dense => Self::Dense, + } + } +} + +type PickerLoader = Arc; enum BackgroundEvent { - PageLoaded { + Page { request_token: usize, search_token: Option, page: std::io::Result, }, + Preview { + thread_id: ThreadId, + preview: std::io::Result>, + }, + Transcript { + thread_id: ThreadId, + transcript: std::io::Result, + }, } #[derive(Clone)] @@ -131,12 +265,31 @@ struct PickerPage { reached_scan_cap: bool, } -/// Interactive session picker that lists app-server threads with simple search -/// and pagination. +#[derive(Clone)] +struct SessionPickerViewPersistence { + codex_home: PathBuf, + active_profile: Option, +} + +struct SessionPickerRunOptions { + show_all: bool, + filter_cwd: Option, + local_filter_cwd: Option, + action: SessionPickerAction, + launch_context: SessionPickerLaunchContext, + provider_filter: ProviderFilter, + initial_density: SessionListDensity, + view_persistence: Option, + pager_keymap: PagerKeymap, +} + +/// Interactive session picker that lists app-server threads with simple search, +/// lazy transcript previews, and pagination. /// -/// The picker displays sessions in a table with timestamp columns (created/updated), -/// git branch, working directory, and conversation preview. Users can toggle -/// between sorting by creation time and last-updated time using the Tab key. +/// Sessions render as compact multi-line records with stable metadata first and +/// the conversation preview last. Users can focus Sort/Filter toolbar controls +/// with Tab, change the focused control with the arrow keys, and expand the +/// selected session with Ctrl+E to load recent transcript context on demand. /// /// Sessions are loaded on-demand via cursor-based pagination. The backend /// `thread/list` API returns pages ordered by the selected sort key, and the @@ -152,22 +305,78 @@ pub async fn run_resume_picker_with_app_server( show_all: bool, include_non_interactive: bool, app_server: AppServerSession, +) -> Result { + run_resume_picker_with_launch_context( + tui, + config, + show_all, + include_non_interactive, + app_server, + SessionPickerLaunchContext::Startup, + ) + .await +} + +pub async fn run_resume_picker_from_existing_session_with_app_server( + tui: &mut Tui, + config: &Config, + show_all: bool, + include_non_interactive: bool, + app_server: AppServerSession, +) -> Result { + run_resume_picker_with_launch_context( + tui, + config, + show_all, + include_non_interactive, + app_server, + SessionPickerLaunchContext::ExistingSession, + ) + .await +} + +async fn run_resume_picker_with_launch_context( + tui: &mut Tui, + config: &Config, + show_all: bool, + include_non_interactive: bool, + app_server: AppServerSession, + launch_context: SessionPickerLaunchContext, ) -> Result { let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let is_remote = app_server.is_remote(); let cwd_filter = picker_cwd_filter( config.cwd.as_path(), - show_all, + /*show_all*/ false, is_remote, app_server.remote_cwd_override(), ); + let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); + let provider_filter = picker_provider_filter(config, is_remote); + let pager_keymap = picker_pager_keymap(config)?; + let options = SessionPickerRunOptions { + show_all, + filter_cwd: cwd_filter, + local_filter_cwd, + action: SessionPickerAction::Resume, + launch_context, + provider_filter, + initial_density: SessionListDensity::from(config.tui_session_picker_view), + view_persistence: Some(SessionPickerViewPersistence { + codex_home: config.codex_home.to_path_buf(), + active_profile: config.active_profile.clone(), + }), + pager_keymap, + }; run_session_picker_with_loader( tui, - config, - show_all, - SessionPickerAction::Resume, - is_remote, - spawn_app_server_page_loader(app_server, cwd_filter, include_non_interactive, bg_tx), + options, + spawn_app_server_page_loader( + app_server, + include_non_interactive, + raw_reasoning_visibility(config), + bg_tx, + ), bg_rx, ) .await @@ -183,18 +392,35 @@ pub async fn run_fork_picker_with_app_server( let is_remote = app_server.is_remote(); let cwd_filter = picker_cwd_filter( config.cwd.as_path(), - show_all, + /*show_all*/ false, is_remote, app_server.remote_cwd_override(), ); + let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, is_remote); + let provider_filter = picker_provider_filter(config, is_remote); + let pager_keymap = picker_pager_keymap(config)?; + let options = SessionPickerRunOptions { + show_all, + filter_cwd: cwd_filter, + local_filter_cwd, + action: SessionPickerAction::Fork, + launch_context: SessionPickerLaunchContext::Startup, + provider_filter, + initial_density: SessionListDensity::from(config.tui_session_picker_view), + view_persistence: Some(SessionPickerViewPersistence { + codex_home: config.codex_home.to_path_buf(), + active_profile: config.active_profile.clone(), + }), + pager_keymap, + }; run_session_picker_with_loader( tui, - config, - show_all, - SessionPickerAction::Fork, - is_remote, + options, spawn_app_server_page_loader( - app_server, cwd_filter, /*include_non_interactive*/ false, bg_tx, + app_server, + /*include_non_interactive*/ false, + raw_reasoning_visibility(config), + bg_tx, ), bg_rx, ) @@ -203,32 +429,24 @@ pub async fn run_fork_picker_with_app_server( async fn run_session_picker_with_loader( tui: &mut Tui, - config: &Config, - show_all: bool, - action: SessionPickerAction, - is_remote: bool, - page_loader: PageLoader, + options: SessionPickerRunOptions, + picker_loader: PickerLoader, bg_rx: mpsc::UnboundedReceiver, ) -> Result { let alt = AltScreenGuard::enter(tui); - let provider_filter = if is_remote { - ProviderFilter::Any - } else { - ProviderFilter::MatchDefault(config.model_provider_id.to_string()) - }; - // Remote sessions live in the server's filesystem namespace, so the client - // process cwd is not a meaningful row filter. Local cwd filtering and explicit - // remote --cd filtering are handled server-side in thread/list. - let filter_cwd = None; - let mut state = PickerState::new( alt.tui.frame_requester(), - page_loader, - provider_filter, - show_all, - filter_cwd, - action, + picker_loader, + options.provider_filter, + options.show_all, + options.filter_cwd, + options.action, ); + state.local_filter_cwd = options.local_filter_cwd; + state.density = options.initial_density; + state.view_persistence = options.view_persistence; + state.pager_keymap = options.pager_keymap; + state.launch_context = options.launch_context; state.start_initial_load(); state.request_frame(); @@ -238,6 +456,10 @@ async fn run_session_picker_with_loader( loop { tokio::select! { Some(ev) = tui_events.next() => { + if state.overlay.is_some() { + state.handle_overlay_event(alt.tui, ev)?; + continue; + } match ev { TuiEvent::Key(key) => { if matches!(key.kind, KeyEventKind::Release) { @@ -249,11 +471,15 @@ async fn run_session_picker_with_loader( } TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { - let list_height = size.height.saturating_sub(4) as usize; - state.update_view_rows(list_height); + let list_height = + size.height.saturating_sub(PICKER_CHROME_HEIGHT) as usize; + state.update_viewport(list_height, list_viewport_width(size.width)); state.ensure_minimum_rows_for_view(list_height); } draw_picker(alt.tui, &state)?; + if state.note_transcript_loading_frame_drawn() { + state.open_pending_transcript_if_ready(); + } } _ => {} } @@ -269,6 +495,32 @@ async fn run_session_picker_with_loader( Ok(SessionSelection::StartFresh) } +fn raw_reasoning_visibility(config: &Config) -> RawReasoningVisibility { + if config.show_raw_agent_reasoning { + RawReasoningVisibility::Visible + } else { + RawReasoningVisibility::Hidden + } +} + +fn local_picker_cwd_filter(cwd_filter: &Option, is_remote: bool) -> Option { + if is_remote { None } else { cwd_filter.clone() } +} + +fn picker_provider_filter(config: &Config, is_remote: bool) -> ProviderFilter { + if is_remote { + ProviderFilter::Any + } else { + ProviderFilter::MatchDefault(config.model_provider_id.to_string()) + } +} + +fn picker_pager_keymap(config: &Config) -> Result { + RuntimeKeymap::from_config(&config.tui_keymap) + .map(|keymap| keymap.pager) + .map_err(|err| color_eyre::eyre::eyre!("invalid keymap configuration: {err}")) +} + fn picker_cwd_filter( config_cwd: &Path, show_all: bool, @@ -286,37 +538,57 @@ fn picker_cwd_filter( fn spawn_app_server_page_loader( app_server: AppServerSession, - cwd_filter: Option, include_non_interactive: bool, + raw_reasoning_visibility: RawReasoningVisibility, bg_tx: mpsc::UnboundedSender, -) -> PageLoader { - let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); +) -> PickerLoader { + let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); tokio::spawn(async move { let mut app_server = app_server; while let Some(request) = request_rx.recv().await { - let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor); - let page = load_app_server_page( - &mut app_server, - cursor, - cwd_filter.as_deref(), - request.provider_filter, - request.sort_key, - include_non_interactive, - ) - .await; - let _ = bg_tx.send(BackgroundEvent::PageLoaded { - request_token: request.request_token, - search_token: request.search_token, - page, - }); + match request { + PickerLoadRequest::Page(request) => { + let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor); + let page = load_app_server_page( + &mut app_server, + cursor, + request.cwd_filter.as_deref(), + request.provider_filter, + request.sort_key, + include_non_interactive, + ) + .await; + let _ = bg_tx.send(BackgroundEvent::Page { + request_token: request.request_token, + search_token: request.search_token, + page, + }); + } + PickerLoadRequest::Preview { thread_id } => { + let preview = load_transcript_preview(&mut app_server, thread_id).await; + let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview }); + } + PickerLoadRequest::Transcript { thread_id } => { + let transcript = load_session_transcript( + &mut app_server, + thread_id, + raw_reasoning_visibility, + ) + .await; + let _ = bg_tx.send(BackgroundEvent::Transcript { + thread_id, + transcript, + }); + } + } } if let Err(err) = app_server.shutdown().await { warn!(%err, "Failed to shut down app-server picker session"); } }); - Arc::new(move |request: PageLoadRequest| { + Arc::new(move |request: PickerLoadRequest| { let _ = request_tx.send(request); }) } @@ -329,9 +601,6 @@ fn sort_key_label(sort_key: ThreadSortKey) -> &'static str { } } -const CREATED_COLUMN_LABEL: &str = "Created"; -const UPDATED_COLUMN_LABEL: &str = "Updated"; - /// RAII guard that ensures we leave the alt-screen on scope exit. struct AltScreenGuard<'a> { tui: &'a mut Tui, @@ -359,18 +628,33 @@ struct PickerState { seen_rows: HashSet, selected: usize, scroll_top: usize, + pending_page_down_target: Option, + frozen_footer_percent: Option, query: String, search_state: SearchState, next_request_token: usize, next_search_token: usize, - page_loader: PageLoader, + picker_loader: PickerLoader, view_rows: Option, + view_width: Option, provider_filter: ProviderFilter, - show_all: bool, + filter_mode: SessionFilterMode, filter_cwd: Option, + local_filter_cwd: Option, + toolbar_focus: ToolbarControl, + density: SessionListDensity, + launch_context: SessionPickerLaunchContext, + view_persistence: Option, action: SessionPickerAction, sort_key: ThreadSortKey, inline_error: Option, + expanded_thread_id: Option, + transcript_previews: HashMap, + transcript_cells: HashMap, + pending_transcript_open: Option, + transcript_loading_frame_shown: bool, + overlay: Option, + pager_keymap: PagerKeymap, } struct PaginationState { @@ -398,6 +682,31 @@ enum SearchState { Active { token: usize }, } +#[derive(Clone)] +enum TranscriptPreviewState { + Loading, + Loaded(Vec), + Failed, +} + +enum SessionTranscriptState { + Loading, + Loaded(TranscriptCells), + Failed, +} + +#[derive(Clone)] +struct TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker, + text: String, +} + +#[derive(Clone, Copy)] +enum TranscriptPreviewSpeaker { + User, + Assistant, +} + enum LoadTrigger { Scroll, Search { token: usize }, @@ -441,6 +750,57 @@ async fn load_app_server_page( }) } +async fn load_transcript_preview( + app_server: &mut AppServerSession, + thread_id: ThreadId, +) -> std::io::Result> { + const MAX_PREVIEW_LINES: usize = 6; + + let thread = app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + .map_err(std::io::Error::other)?; + let mut lines = thread + .turns + .iter() + .flat_map(|turn| turn.items.iter()) + .filter_map(|item| match item { + ThreadItem::UserMessage { content, .. } => Some(TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::User, + text: content + .iter() + .filter_map(|input| match input { + codex_app_server_protocol::UserInput::Text { text, .. } => { + Some(text.as_str()) + } + _ => None, + }) + .collect::>() + .join(" "), + }), + ThreadItem::AgentMessage { text, .. } => Some(TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::Assistant, + text: text.clone(), + }), + _ => None, + }) + .flat_map(|line| { + line.text + .lines() + .filter(|text| !text.trim().is_empty()) + .map(move |text| TranscriptPreviewLine { + speaker: line.speaker, + text: text.trim().to_string(), + }) + .collect::>() + }) + .collect::>(); + if lines.len() > MAX_PREVIEW_LINES { + lines.drain(..lines.len() - MAX_PREVIEW_LINES); + } + Ok(lines) +} + impl SearchState { fn active_token(&self) -> Option { match self { @@ -493,6 +853,26 @@ impl Row { { return true; } + if self + .thread_id + .is_some_and(|thread_id| thread_id.to_string().to_lowercase().contains(query)) + { + return true; + } + if self + .git_branch + .as_ref() + .is_some_and(|branch| branch.to_lowercase().contains(query)) + { + return true; + } + if self + .cwd + .as_ref() + .is_some_and(|cwd| cwd.to_string_lossy().to_lowercase().contains(query)) + { + return true; + } false } } @@ -500,7 +880,7 @@ impl Row { impl PickerState { fn new( requester: FrameRequester, - page_loader: PageLoader, + picker_loader: PickerLoader, provider_filter: ProviderFilter, show_all: bool, filter_cwd: Option, @@ -520,18 +900,33 @@ impl PickerState { seen_rows: HashSet::new(), selected: 0, scroll_top: 0, + pending_page_down_target: None, + frozen_footer_percent: None, query: String::new(), search_state: SearchState::Idle, next_request_token: 0, next_search_token: 0, - page_loader, + picker_loader, view_rows: None, + view_width: None, provider_filter, - show_all, + filter_mode: SessionFilterMode::from_show_all(show_all, filter_cwd.as_deref()), + local_filter_cwd: filter_cwd.clone(), filter_cwd, + toolbar_focus: ToolbarControl::Filter, + density: SessionListDensity::Comfortable, + launch_context: SessionPickerLaunchContext::Startup, + view_persistence: None, action, sort_key: ThreadSortKey::UpdatedAt, inline_error: None, + expanded_thread_id: None, + transcript_previews: HashMap::new(), + transcript_cells: HashMap::new(), + pending_transcript_open: None, + transcript_loading_frame_shown: false, + overlay: None, + pager_keymap: RuntimeKeymap::defaults().pager, } } @@ -539,12 +934,111 @@ impl PickerState { self.requester.schedule_frame(); } + fn is_transcript_loading(&self) -> bool { + self.pending_transcript_open.is_some() + } + + fn note_transcript_loading_frame_drawn(&mut self) -> bool { + if self.pending_transcript_open.is_some() { + self.transcript_loading_frame_shown = true; + true + } else { + false + } + } + + fn open_pending_transcript_if_ready(&mut self) { + if !self.transcript_loading_frame_shown { + return; + } + let Some(thread_id) = self.pending_transcript_open else { + return; + }; + let Some(SessionTranscriptState::Loaded(cells)) = self.transcript_cells.get(&thread_id) + else { + return; + }; + self.overlay = Some(Overlay::new_transcript( + cells.clone(), + self.pager_keymap.clone(), + )); + self.pending_transcript_open = None; + self.transcript_loading_frame_shown = false; + self.request_frame(); + } + + fn begin_transcript_loading(&mut self, thread_id: ThreadId) { + self.pending_transcript_open = Some(thread_id); + self.transcript_loading_frame_shown = false; + self.request_frame(); + } + + fn handle_overlay_event(&mut self, tui: &mut Tui, event: TuiEvent) -> Result<()> { + let Some(overlay) = &mut self.overlay else { + return Ok(()); + }; + overlay.handle_event(tui, event)?; + if overlay.is_done() { + self.overlay = None; + self.request_frame(); + } + Ok(()) + } + + fn open_selected_transcript(&mut self) { + let Some(row) = self.filtered_rows.get(self.selected) else { + return; + }; + let Some(thread_id) = row.thread_id else { + self.inline_error = Some("No transcript available for this session".to_string()); + self.request_frame(); + return; + }; + + match self.transcript_cells.get(&thread_id) { + Some(SessionTranscriptState::Loaded(_)) => { + self.begin_transcript_loading(thread_id); + } + Some(SessionTranscriptState::Loading) => { + self.begin_transcript_loading(thread_id); + } + Some(SessionTranscriptState::Failed) | None => { + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Loading); + self.begin_transcript_loading(thread_id); + (self.picker_loader)(PickerLoadRequest::Transcript { thread_id }); + } + } + } + + fn handle_transcript_loading_key(&mut self, key: KeyEvent) -> Option { + match key { + KeyEvent { + code: KeyCode::Char('c'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => Some(SessionSelection::Exit), + _ => None, + } + } + async fn handle_key(&mut self, key: KeyEvent) -> Result> { self.inline_error = None; + if self.is_transcript_loading() { + return Ok(self.handle_transcript_loading_key(key)); + } + if !matches!(key.code, KeyCode::PageDown) { + self.pending_page_down_target = None; + } match key { KeyEvent { code: KeyCode::Esc, .. - } => return Ok(Some(SessionSelection::StartFresh)), + } => { + if self.query.is_empty() { + return Ok(Some(SessionSelection::StartFresh)); + } + self.clear_query_preserving_selection(); + } KeyEvent { code: KeyCode::Char('c'), modifiers, @@ -552,6 +1046,48 @@ impl PickerState { } if modifiers.contains(KeyModifiers::CONTROL) => { return Ok(Some(SessionSelection::Exit)); } + KeyEvent { + code: KeyCode::Char('t'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.open_selected_transcript(); + } + KeyEvent { + code: KeyCode::Char('e'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.toggle_selected_expansion(); + } + KeyEvent { + code: KeyCode::Char('\u{0014}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^T */ => { + self.open_selected_transcript(); + } + KeyEvent { + code: KeyCode::Char('\u{0005}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^E */ => { + self.toggle_selected_expansion(); + } + KeyEvent { + code: KeyCode::Char('o'), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) => { + self.toggle_density().await; + } + KeyEvent { + code: KeyCode::Char('\u{000f}'), + modifiers: KeyModifiers::NONE, + .. + } /* ^O */ => { + self.toggle_density().await; + } KeyEvent { code: KeyCode::Enter, .. @@ -634,22 +1170,66 @@ impl PickerState { } } KeyEvent { - code: KeyCode::PageDown, + code: KeyCode::Home, .. } => { if !self.filtered_rows.is_empty() { - let step = self.view_rows.unwrap_or(10).max(1); - let max_index = self.filtered_rows.len().saturating_sub(1); - self.selected = (self.selected + step).min(max_index); + self.selected = 0; + self.ensure_selected_visible(); + self.request_frame(); + } + } + KeyEvent { + code: KeyCode::End, .. + } => { + if !self.filtered_rows.is_empty() { + self.selected = self.filtered_rows.len().saturating_sub(1); self.ensure_selected_visible(); self.maybe_load_more_for_scroll(); self.request_frame(); } } + KeyEvent { + code: KeyCode::PageDown, + .. + } => { + if !self.filtered_rows.is_empty() { + let step = self.view_rows.unwrap_or(10).max(1); + let target = self.selected.saturating_add(step); + let max_index = self.filtered_rows.len().saturating_sub(1); + if target > max_index && self.pagination.next_cursor.is_some() { + self.pending_page_down_target = Some(target); + self.load_more_if_needed(LoadTrigger::Scroll); + } else { + self.selected = target.min(max_index); + self.ensure_selected_visible(); + self.maybe_load_more_for_scroll(); + } + self.request_frame(); + } + } KeyEvent { code: KeyCode::Tab, .. } => { - self.toggle_sort_key(); + self.focus_next_toolbar_control(); + self.request_frame(); + } + KeyEvent { + code: KeyCode::BackTab, + .. + } => { + self.focus_previous_toolbar_control(); + self.request_frame(); + } + KeyEvent { + code: KeyCode::Left, + .. + } + | KeyEvent { + code: KeyCode::Right, + .. + } => { + self.change_focused_toolbar_value(); self.request_frame(); } KeyEvent { @@ -686,6 +1266,8 @@ impl PickerState { self.filtered_rows.clear(); self.seen_rows.clear(); self.selected = 0; + self.pending_page_down_target = None; + self.frozen_footer_percent = None; let search_token = if self.query.is_empty() { self.search_state = SearchState::Idle; @@ -703,18 +1285,19 @@ impl PickerState { }); self.request_frame(); - (self.page_loader)(PageLoadRequest { + (self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest { cursor: None, request_token, search_token, + cwd_filter: self.active_cwd_filter(), provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, - }); + })); } async fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> { match event { - BackgroundEvent::PageLoaded { + BackgroundEvent::Page { request_token, search_token, page, @@ -729,9 +1312,44 @@ impl PickerState { self.pagination.loading = LoadingState::Idle; let page = page.map_err(color_eyre::Report::from)?; self.ingest_page(page); + self.complete_pending_page_down(); let completed_token = pending.search_token.or(search_token); self.continue_search_if_token_matches(completed_token); } + BackgroundEvent::Preview { thread_id, preview } => { + self.transcript_previews.insert( + thread_id, + match preview { + Ok(lines) => TranscriptPreviewState::Loaded(lines), + Err(_) => TranscriptPreviewState::Failed, + }, + ); + self.request_frame(); + } + BackgroundEvent::Transcript { + thread_id, + transcript, + } => match transcript { + Ok(cells) => { + let should_open = self.pending_transcript_open == Some(thread_id); + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Loaded(cells.clone())); + if should_open { + self.open_pending_transcript_if_ready(); + } + self.request_frame(); + } + Err(_) => { + self.transcript_cells + .insert(thread_id, SessionTranscriptState::Failed); + if self.pending_transcript_open == Some(thread_id) { + self.pending_transcript_open = None; + self.transcript_loading_frame_shown = false; + self.inline_error = Some("Could not load transcript preview".to_string()); + } + self.request_frame(); + } + }, } Ok(()) } @@ -741,6 +1359,7 @@ impl PickerState { self.pagination.num_scanned_files = 0; self.pagination.reached_scan_cap = false; self.pagination.loading = LoadingState::Idle; + self.frozen_footer_percent = None; } fn ingest_page(&mut self, page: PickerPage) { @@ -770,6 +1389,27 @@ impl PickerState { self.apply_filter(); } + fn complete_pending_page_down(&mut self) { + let Some(target) = self.pending_page_down_target else { + return; + }; + if self.filtered_rows.is_empty() { + return; + } + + let max_index = self.filtered_rows.len().saturating_sub(1); + if target > max_index && self.pagination.next_cursor.is_some() { + self.load_more_if_needed(LoadTrigger::Scroll); + return; + } + + self.pending_page_down_target = None; + self.selected = target.min(max_index); + self.ensure_selected_visible(); + self.maybe_load_more_for_scroll(); + self.request_frame(); + } + fn apply_filter(&mut self) { let base_iter = self .all_rows @@ -792,10 +1432,10 @@ impl PickerState { } fn row_matches_filter(&self, row: &Row) -> bool { - if self.show_all { + if self.filter_mode == SessionFilterMode::All { return true; } - let Some(filter_cwd) = self.filter_cwd.as_ref() else { + let Some(filter_cwd) = self.local_filter_cwd.as_ref() else { return true; }; let Some(row_cwd) = row.cwd.as_ref() else { @@ -828,6 +1468,26 @@ impl PickerState { self.load_more_if_needed(LoadTrigger::Search { token }); } + fn clear_query_preserving_selection(&mut self) { + let selected_key = self + .filtered_rows + .get(self.selected) + .and_then(Row::seen_key); + self.query.clear(); + self.search_state = SearchState::Idle; + self.apply_filter(); + if let Some(selected_key) = selected_key + && let Some(index) = self + .filtered_rows + .iter() + .position(|row| row.seen_key().as_ref() == Some(&selected_key)) + { + self.selected = index; + self.ensure_selected_visible(); + self.request_frame(); + } + } + fn continue_search_if_needed(&mut self) { let Some(token) = self.search_state.active_token() else { return; @@ -860,20 +1520,15 @@ impl PickerState { self.scroll_top = 0; return; } - let capacity = self.view_rows.unwrap_or(self.filtered_rows.len()).max(1); - + let viewport_rows = self.view_rows.unwrap_or(usize::MAX).max(1); if self.selected < self.scroll_top { self.scroll_top = self.selected; - } else { - let last_visible = self.scroll_top.saturating_add(capacity - 1); - if self.selected > last_visible { - self.scroll_top = self.selected.saturating_sub(capacity - 1); - } } - - let max_start = self.filtered_rows.len().saturating_sub(capacity); - if self.scroll_top > max_start { - self.scroll_top = max_start; + while self.rendered_height_between(self.scroll_top, self.selected) + > self.available_content_rows(viewport_rows) + && self.scroll_top < self.selected + { + self.scroll_top += 1; } } @@ -881,10 +1536,15 @@ impl PickerState { if minimum_rows == 0 { return; } - if self.filtered_rows.len() >= minimum_rows { + if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() { return; } - if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() { + let rendered_rows = if self.filtered_rows.is_empty() { + 0 + } else { + self.rendered_height_between(/*start*/ 0, self.filtered_rows.len() - 1) + }; + if rendered_rows >= self.available_content_rows(minimum_rows) { return; } if let Some(token) = self.search_state.active_token() { @@ -894,8 +1554,9 @@ impl PickerState { } } - fn update_view_rows(&mut self, rows: usize) { + fn update_viewport(&mut self, rows: usize, width: u16) { self.view_rows = if rows == 0 { None } else { Some(rows) }; + self.view_width = Some(width); self.ensure_selected_visible(); } @@ -922,6 +1583,7 @@ impl PickerState { let Some(cursor) = self.pagination.next_cursor.clone() else { return; }; + self.freeze_footer_percent(); let request_token = self.allocate_request_token(); let search_token = match trigger { LoadTrigger::Scroll => None, @@ -933,13 +1595,19 @@ impl PickerState { }); self.request_frame(); - (self.page_loader)(PageLoadRequest { + (self.picker_loader)(PickerLoadRequest::Page(PageLoadRequest { cursor: Some(cursor), request_token, search_token, + cwd_filter: self.active_cwd_filter(), provider_filter: self.provider_filter.clone(), sort_key: self.sort_key, - }); + })); + } + + fn freeze_footer_percent(&mut self) { + let list_height = self.view_rows.unwrap_or_default().min(u16::MAX as usize) as u16; + self.frozen_footer_percent = Some(picker_footer_scroll_percent(self, list_height)); } fn allocate_request_token(&mut self) -> usize { @@ -966,6 +1634,163 @@ impl PickerState { }; self.start_initial_load(); } + + fn toggle_filter_mode(&mut self) { + let next_filter_mode = self.filter_mode.toggle(self.filter_cwd.as_deref()); + if self.filter_mode == next_filter_mode { + return; + } + self.filter_mode = next_filter_mode; + self.start_initial_load(); + } + + fn active_cwd_filter(&self) -> Option { + match self.filter_mode { + SessionFilterMode::Cwd => self.filter_cwd.clone(), + SessionFilterMode::All => None, + } + } + + fn focus_previous_toolbar_control(&mut self) { + self.toolbar_focus = self.toolbar_focus.previous(); + } + + fn focus_next_toolbar_control(&mut self) { + self.toolbar_focus = self.toolbar_focus.next(); + } + + fn change_focused_toolbar_value(&mut self) { + match self.toolbar_focus { + ToolbarControl::Sort => self.toggle_sort_key(), + ToolbarControl::Filter => self.toggle_filter_mode(), + } + } + + async fn toggle_density(&mut self) { + self.density = self.density.toggle(); + self.ensure_selected_visible(); + if let Err(err) = self.persist_density().await { + warn!(error = %err, "failed to persist session picker view mode"); + self.inline_error = Some(format!("Failed to save view mode: {err}")); + } + self.request_frame(); + } + + async fn persist_density(&self) -> Result<()> { + let Some(persistence) = &self.view_persistence else { + return Ok(()); + }; + + ConfigEditsBuilder::new(&persistence.codex_home) + .with_profile(persistence.active_profile.as_deref()) + .set_session_picker_view(SessionPickerViewMode::from(self.density)) + .apply() + .await + .map_err(|err| color_eyre::eyre::eyre!("failed to write config.toml: {err}"))?; + + Ok(()) + } + + fn toggle_selected_expansion(&mut self) { + let Some(row) = self.filtered_rows.get(self.selected) else { + return; + }; + let Some(thread_id) = row.thread_id else { + return; + }; + if self.expanded_thread_id == Some(thread_id) { + self.expanded_thread_id = None; + self.request_frame(); + return; + } + self.expanded_thread_id = Some(thread_id); + if let std::collections::hash_map::Entry::Vacant(e) = + self.transcript_previews.entry(thread_id) + { + e.insert(TranscriptPreviewState::Loading); + (self.picker_loader)(PickerLoadRequest::Preview { thread_id }); + } + self.request_frame(); + } + + fn rendered_height_between(&self, start: usize, end_inclusive: usize) -> usize { + self.filtered_rows + .get(start..=end_inclusive) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(offset, row)| { + let row_idx = start + offset; + let is_selected = row_idx == self.selected; + let is_expanded = is_selected + && row.thread_id.is_some() + && self.expanded_thread_id == row.thread_id; + render_session_lines( + row, + self, + is_selected, + is_expanded, + /*is_zebra*/ false, + self.view_width.unwrap_or(u16::MAX), + ) + .len() + }) + .sum::() + + self.row_separator_height() * end_inclusive.saturating_sub(start) + } + + fn has_more_above(&self) -> bool { + self.scroll_top > 0 + } + + fn has_more_below(&self, viewport_height: usize) -> bool { + if self.filtered_rows.is_empty() { + return false; + } + if self.pagination.next_cursor.is_some() { + return true; + } + let capacity = self.available_content_rows(viewport_height); + let mut used = 0usize; + for (offset, row) in self.filtered_rows[self.scroll_top..].iter().enumerate() { + let row_idx = self.scroll_top + offset; + let is_selected = row_idx == self.selected; + let is_expanded = + is_selected && row.thread_id.is_some() && self.expanded_thread_id == row.thread_id; + let row_height = render_session_lines( + row, + self, + is_selected, + is_expanded, + /*is_zebra*/ false, + self.view_width.unwrap_or(u16::MAX), + ) + .len(); + let separator_height = usize::from(offset > 0) * self.row_separator_height(); + if used + separator_height + row_height > capacity { + return true; + } + used += separator_height + row_height; + } + false + } + + fn available_content_rows(&self, viewport_height: usize) -> usize { + viewport_height + .saturating_sub(usize::from(self.has_more_above())) + .saturating_sub(usize::from( + self.pagination.next_cursor.is_some() + || self.selected + 1 < self.filtered_rows.len(), + )) + .max(1) + } + + fn row_separator_height(&self) -> usize { + match self.density { + SessionListDensity::Comfortable => 1, + SessionListDensity::Dense => 0, + } + } } fn row_from_app_server_thread(thread: Thread) -> Option { @@ -1036,83 +1861,582 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> { let height = tui.terminal.size()?.height; tui.draw(height, |frame| { let area = frame.area(); - let [header, search, columns, list, hint] = Layout::vertical([ + let [header, _header_gap, search, _search_gap, list, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Min(area.height.saturating_sub(4)), Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(PICKER_CHROME_HEIGHT)), + Constraint::Length(4), ]) .areas(area); + let chrome = |area: Rect| { + Rect::new( + area.x.saturating_add(1), + area.y, + area.width.saturating_sub(2), + area.height, + ) + }; + // Header - let header_line: Line = vec![ - state.action.title().bold().cyan(), - " ".into(), - "Sort:".dim(), - " ".into(), - sort_key_label(state.sort_key).magenta(), - ] - .into(); - frame.render_widget_ref(header_line, header); + let header_title = if default_bg().is_some_and(is_light) { + state.action.title().bold().fg(best_color((0, 100, 0))) + } else { + state.action.title().bold().cyan() + }; + let header_line: Line = vec![header_title].into(); + frame.render_widget_ref(header_line, chrome(header)); // Search line - frame.render_widget_ref(search_line(state), search); + let search = chrome(search); + frame.render_widget_ref(search_line(state, search.width), search); - let metrics = calculate_column_metrics( - &state.filtered_rows, - state.show_all, - state.relative_time_reference.unwrap_or_else(Utc::now), + let list = Rect::new( + list.x.saturating_add(2), + list.y, + list_viewport_width(list.width), + list.height, ); + render_list(frame, list, state); + if state.is_transcript_loading() { + render_transcript_loading_overlay(frame, list); + } - // Column headers and list - render_column_headers(frame, columns, &metrics, state.sort_key); - render_list(frame, list, state, &metrics); - - // Hint line - let action_label = state.action.action_label(); - let hint_line: Line = vec![ - key_hint::plain(KeyCode::Enter).into(), - format!(" to {action_label} ").dim(), - " ".dim(), - key_hint::plain(KeyCode::Esc).into(), - " to start new ".dim(), - " ".dim(), - key_hint::ctrl(KeyCode::Char('c')).into(), - " to quit ".dim(), - " ".dim(), - key_hint::plain(KeyCode::Tab).into(), - " to toggle sort ".dim(), - " ".dim(), - key_hint::plain(KeyCode::Up).into(), - "/".dim(), - key_hint::plain(KeyCode::Down).into(), - " to browse".dim(), - ] - .into(); - frame.render_widget_ref(hint_line, hint); + render_picker_footer(frame, footer, state, list.height); }) } -fn search_line(state: &PickerState) -> Line<'_> { +fn list_viewport_width(width: u16) -> u16 { + width.saturating_sub(PICKER_LIST_HORIZONTAL_INSET) +} + +fn search_line(state: &PickerState, width: u16) -> Line<'_> { if let Some(error) = state.inline_error.as_deref() { return Line::from(error.red()); } - if state.query.is_empty() { - return Line::from("Type to search".dim()); + let search = if state.query.is_empty() { + "Type to search".dim() + } else { + format!("Search: {}", state.query).into() + }; + let mut toolbar = toolbar_line(state, /*compact*/ false); + if toolbar.width() as u16 > width.saturating_sub(2) { + toolbar = toolbar_line(state, /*compact*/ true); } - Line::from(format!("Search: {}", state.query)) + let search_width = UnicodeWidthStr::width(search.content.as_ref()); + let toolbar_width = toolbar.width(); + let spacer_width = width + .saturating_sub((search_width + toolbar_width) as u16) + .max(2) as usize; + let available_search_width = width + .saturating_sub(toolbar_width as u16) + .saturating_sub(spacer_width as u16) as usize; + let search = if search_width > available_search_width { + let truncated = truncate_text(search.content.as_ref(), available_search_width); + if state.query.is_empty() { + truncated.dim() + } else { + truncated.into() + } + } else { + search + }; + + let mut spans = vec![search, " ".repeat(spacer_width).into()]; + spans.extend(toolbar.spans); + spans.into() } -fn render_list( +fn toolbar_line(state: &PickerState, compact: bool) -> Line<'static> { + let mut spans = Vec::new(); + spans.extend(filter_control_spans(state, compact)); + spans.push(" ".dim()); + spans.extend(sort_control_spans(state, compact)); + spans.into() +} + +fn sort_control_spans(state: &PickerState, compact: bool) -> Vec> { + let sort_focused = state.toolbar_focus == ToolbarControl::Sort; + if compact { + return vec![ + "Sort:".dim(), + toolbar_value( + sort_key_label(state.sort_key), + /*active*/ true, + sort_focused, + ), + ]; + } + vec![ + "Sort: ".dim(), + toolbar_value( + sort_key_label(ThreadSortKey::UpdatedAt), + state.sort_key == ThreadSortKey::UpdatedAt, + sort_focused, + ), + toolbar_value( + sort_key_label(ThreadSortKey::CreatedAt), + state.sort_key == ThreadSortKey::CreatedAt, + sort_focused, + ), + ] +} + +fn filter_control_spans(state: &PickerState, compact: bool) -> Vec> { + let filter_focused = state.toolbar_focus == ToolbarControl::Filter; + if compact || state.filter_cwd.is_none() { + return vec![ + "Filter:".dim(), + toolbar_value( + filter_mode_label(state.filter_mode), + /*active*/ true, + filter_focused, + ), + ]; + } + vec![ + "Filter: ".dim(), + toolbar_value( + filter_mode_label(SessionFilterMode::Cwd), + state.filter_mode == SessionFilterMode::Cwd, + filter_focused, + ), + toolbar_value( + filter_mode_label(SessionFilterMode::All), + state.filter_mode == SessionFilterMode::All, + filter_focused, + ), + ] +} + +fn toolbar_value(label: &'static str, active: bool, focused: bool) -> Span<'static> { + if active { + let value = format!("[{label}]"); + if focused { + value.magenta() + } else { + value.into() + } + } else { + format!(" {label} ").dim() + } +} + +fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str { + match filter_mode { + SessionFilterMode::Cwd => "Cwd", + SessionFilterMode::All => "All", + } +} + +struct PickerFooterHint { + key: &'static str, + wide_label: String, + compact_label: String, + priority: u8, +} + +fn render_picker_footer( frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState, - metrics: &ColumnMetrics, + list_height: u16, ) { + if area.width == 0 || area.height == 0 { + return; + } + + let separator = Rect::new(area.x, area.y, area.width, 1); + render_picker_footer_separator( + frame, + separator, + picker_footer_progress_label(state, list_height, area.width), + ); + + let lines = footer_hint_lines(state, area.width); + for (idx, line) in lines.into_iter().enumerate() { + let y = area.y.saturating_add(1 + idx as u16); + if y >= area.bottom() { + break; + } + frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1)); + } +} + +fn render_picker_footer_separator( + frame: &mut crate::custom_terminal::Frame, + area: Rect, + progress_label: String, +) { + if area.width == 0 { + return; + } + + let separator = "─".repeat(area.width as usize); + frame.render_widget_ref(Line::from(separator.dim()), area); + + let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16; + if progress_width < area.width { + let percent_area = Rect::new( + area.x + area.width - progress_width - 1, + area.y, + progress_width, + 1, + ); + frame.render_widget_ref(Line::from(progress_label.dim()), percent_area); + } +} + +fn picker_footer_progress_label(state: &PickerState, list_height: u16, width: u16) -> String { + let position = if state.filtered_rows.is_empty() { + 0 + } else { + state.selected.saturating_add(1) + }; + let total = if state.pagination.loading.is_pending() { + format!("{}…", state.filtered_rows.len()) + } else { + state.filtered_rows.len().to_string() + }; + let percent = picker_footer_percent(state, list_height); + let labels = [ + format!(" {position} / {total} · {percent}% "), + format!(" {position}/{total} · {percent}% "), + format!(" {percent}% "), + ]; + labels + .into_iter() + .find(|label| UnicodeWidthStr::width(label.as_str()) < width as usize) + .unwrap_or_default() +} + +fn picker_footer_percent(state: &PickerState, list_height: u16) -> u8 { + if state.pagination.loading.is_pending() { + return state.frozen_footer_percent.unwrap_or_else(|| { + if state.filtered_rows.is_empty() { + 0 + } else { + picker_footer_scroll_percent(state, list_height) + } + }); + } + + picker_footer_scroll_percent(state, list_height) +} + +fn picker_footer_scroll_percent(state: &PickerState, list_height: u16) -> u8 { + if state.filtered_rows.is_empty() { + return 100; + } + + let content_rows = state.available_content_rows(list_height as usize); + let total_height = + state.rendered_height_between(/*start*/ 0, state.filtered_rows.len() - 1); + let max_scroll = total_height.saturating_sub(content_rows); + if max_scroll == 0 { + return 100; + } + let remaining_height = + state.rendered_height_between(state.scroll_top, state.filtered_rows.len() - 1); + if remaining_height <= content_rows { + return 100; + } + + let skipped_height = if state.scroll_top == 0 { + 0 + } else { + state.rendered_height_between(/*start*/ 0, state.scroll_top - 1) + }; + (((skipped_height.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() as u8 +} + +fn footer_hint_lines(state: &PickerState, width: u16) -> Vec> { + if state.is_transcript_loading() { + let hints = [ + PickerFooterHint { + key: "loading", + wide_label: String::from("transcript"), + compact_label: String::from("transcript"), + priority: 0, + }, + PickerFooterHint { + key: "ctrl+c", + wide_label: String::from("quit"), + compact_label: String::from("quit"), + priority: 1, + }, + ]; + let line = fit_footer_hints(&hints, FooterHintLabelMode::Wide, width) + .or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::Compact, width)) + .or_else(|| fit_footer_hints(&hints, FooterHintLabelMode::KeyOnly, width)) + .unwrap_or_default(); + return vec![line, Line::default()]; + } + + let action_label = state.action.action_label(); + let (esc_label, esc_compact_label) = if state.query.is_empty() { + match state.launch_context { + SessionPickerLaunchContext::Startup => ("start new", "new"), + SessionPickerLaunchContext::ExistingSession => ("exit", "exit"), + } + } else { + ("clear search", "clear") + }; + let ctrl_c_label = match state.launch_context { + SessionPickerLaunchContext::Startup => "quit", + SessionPickerLaunchContext::ExistingSession => "exit", + }; + let density_label = match state.density { + SessionListDensity::Comfortable => "dense view", + SessionListDensity::Dense => "comfortable view", + }; + let density_compact_label = match state.density { + SessionListDensity::Comfortable => "dense", + SessionListDensity::Dense => "comfy", + }; + let first_row_hints = vec![ + PickerFooterHint { + key: "enter", + wide_label: action_label.to_string(), + compact_label: action_label.to_string(), + priority: 0, + }, + PickerFooterHint { + key: "esc", + wide_label: esc_label.to_string(), + compact_label: esc_compact_label.to_string(), + priority: 1, + }, + PickerFooterHint { + key: "ctrl+c", + wide_label: ctrl_c_label.to_string(), + compact_label: ctrl_c_label.to_string(), + priority: 2, + }, + PickerFooterHint { + key: "tab", + wide_label: String::from("focus sort/filter"), + compact_label: String::from("focus"), + priority: 7, + }, + PickerFooterHint { + key: "←/→", + wide_label: String::from("change option"), + compact_label: String::from("option"), + priority: 8, + }, + ]; + let second_row_hints = vec![ + PickerFooterHint { + key: "ctrl+o", + wide_label: density_label.to_string(), + compact_label: density_compact_label.to_string(), + priority: 3, + }, + PickerFooterHint { + key: "ctrl+t", + wide_label: String::from("transcript"), + compact_label: String::from("preview"), + priority: 4, + }, + PickerFooterHint { + key: "ctrl+e", + wide_label: String::from("expand"), + compact_label: String::from("exp"), + priority: 6, + }, + PickerFooterHint { + key: "↑/↓", + wide_label: String::from("browse"), + compact_label: String::from("browse"), + priority: 5, + }, + ]; + + vec![ + hint_line_for_row(&first_row_hints, width), + hint_line_for_row(&second_row_hints, width), + ] +} + +fn hint_line_for_row(hints: &[PickerFooterHint], width: u16) -> Line<'static> { + if width >= FOOTER_COMPACT_BREAKPOINT + && let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Wide, width) + { + return line; + } + if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::Compact, width) { + return line; + } + if let Some(line) = fit_footer_hints(hints, FooterHintLabelMode::KeyOnly, width) { + return line; + } + + let mut retained = (0..hints.len()).collect::>(); + retained.sort_by_key(|idx| hints[*idx].priority); + for retain_count in (1..=retained.len()).rev() { + let mut candidate_indices = retained[..retain_count].to_vec(); + candidate_indices.sort_unstable(); + let candidate = candidate_indices + .iter() + .map(|idx| &hints[*idx]) + .collect::>(); + if let Some(line) = fit_footer_hint_refs(&candidate, FooterHintLabelMode::KeyOnly, width) { + return line; + } + } + Line::default() +} + +fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + + let message = "Loading transcript…"; + let message_width = UnicodeWidthStr::width(message) as u16; + let overlay_width = if area.width >= message_width.saturating_add(10) { + message_width + 10 + } else { + area.width + }; + let overlay_height = if area.height >= 3 { 3 } else { 1 }; + let overlay = Rect::new( + area.x + area.width.saturating_sub(overlay_width) / 2, + area.y + area.height.saturating_sub(overlay_height) / 2, + overlay_width, + overlay_height, + ); + let style = transcript_loading_overlay_style(); + for y in overlay.y..overlay.bottom() { + for x in overlay.x..overlay.right() { + frame.buffer[(x, y)].set_symbol(" ").set_style(style); + } + } + + let message = truncate_text(message, overlay.width as usize); + let message_width = UnicodeWidthStr::width(message.as_str()) as u16; + let line = Rect::new( + overlay.x + overlay.width.saturating_sub(message_width) / 2, + overlay.y + overlay.height / 2, + message_width.min(overlay.width), + 1, + ); + frame.render_widget_ref(Line::from(message.bold()), line); +} + +fn transcript_loading_overlay_style() -> Style { + let Some(bg) = default_bg() else { + return Style::default().bg(Color::DarkGray); + }; + let (overlay, alpha) = if is_light(bg) { + ((0, 0, 0), 0.08) + } else { + ((255, 255, 255), 0.14) + }; + Style::default().bg(best_color(blend(overlay, bg, alpha))) +} + +#[derive(Clone, Copy)] +enum FooterHintLabelMode { + Wide, + Compact, + KeyOnly, +} + +fn fit_footer_hints( + hints: &[PickerFooterHint], + mode: FooterHintLabelMode, + width: u16, +) -> Option> { + let hint_refs = hints.iter().collect::>(); + fit_footer_hint_refs(&hint_refs, mode, width) +} + +fn fit_footer_hint_refs( + hints: &[&PickerFooterHint], + mode: FooterHintLabelMode, + width: u16, +) -> Option> { + let gap_width = FOOTER_HINT_GAP; + if footer_hints_width(hints, mode, gap_width) > width as usize { + return None; + } + + let mut spans = vec![ + " ".repeat(FOOTER_HINT_LEFT_PADDING) + .set_style(footer_hint_label_style()), + ]; + for (idx, hint) in hints.iter().enumerate() { + if idx > 0 { + spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style())); + } + spans.push(hint.key.set_style(footer_hint_key_style())); + let label = match mode { + FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()), + FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()), + FooterHintLabelMode::KeyOnly => None, + }; + if let Some(label) = label { + spans.push(" ".set_style(footer_hint_label_style())); + spans.push(label.to_string().set_style(footer_hint_label_style())); + } + } + Some(spans.into()) +} + +fn footer_hint_key_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Black) + } else { + Style::default() + } +} + +fn footer_hint_label_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::DarkGray) + } else { + Style::default().dim() + } +} + +fn footer_hints_width( + hints: &[&PickerFooterHint], + mode: FooterHintLabelMode, + gap_width: usize, +) -> usize { + FOOTER_HINT_LEFT_PADDING + + hints + .iter() + .enumerate() + .map(|(idx, hint)| { + let label_width = match mode { + FooterHintLabelMode::Wide => { + 1 + UnicodeWidthStr::width(hint.wide_label.as_str()) + } + FooterHintLabelMode::Compact => { + 1 + UnicodeWidthStr::width(hint.compact_label.as_str()) + } + FooterHintLabelMode::KeyOnly => 0, + }; + let hint_width = UnicodeWidthStr::width(hint.key) + label_width; + if idx == 0 { + hint_width + } else { + hint_width + gap_width + } + }) + .sum::() +} + +fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) { if area.height == 0 { return; } + Clear.render(area, frame.buffer); let rows = &state.filtered_rows; if rows.is_empty() { @@ -1121,120 +2445,742 @@ fn render_list( return; } - let capacity = area.height as usize; - let start = state.scroll_top.min(rows.len().saturating_sub(1)); - let end = rows.len().min(start + capacity); - let labels = &metrics.labels; - let mut y = area.y; - - let visibility = column_visibility(area.width, metrics, state.sort_key); - let max_created_width = metrics.max_created_width; - let max_updated_width = metrics.max_updated_width; - let max_branch_width = metrics.max_branch_width; - let max_cwd_width = metrics.max_cwd_width; - - for (idx, (row, (created_label, updated_label, branch_label, cwd_label))) in rows[start..end] - .iter() - .zip(labels[start..end].iter()) - .enumerate() - { - let is_sel = start + idx == state.selected; - let marker = if is_sel { "> ".bold() } else { " ".into() }; - let marker_width = 2usize; - let created_span = if visibility.show_created { - Some(Span::from(format!("{created_label: = vec![marker]; - if let Some(created) = created_span { - spans.push(created); - spans.push(" ".into()); - } - if let Some(updated) = updated_span { - spans.push(updated); - spans.push(" ".into()); - } - if let Some(branch) = branch_span { - spans.push(branch); - spans.push(" ".into()); - } - if let Some(cwd) = cwd_span { - spans.push(cwd); - spans.push(" ".into()); - } - if add_leading_gap { - spans.push(" ".into()); - } - spans.push(preview.into()); - - let line: Line = spans.into(); - let rect = Rect::new(area.x, y, area.width, 1); - frame.render_widget_ref(line, rect); - y = y.saturating_add(1); + let show_more_above = state.has_more_above(); + let show_more_below = state.has_more_below(area.height as usize); + let content_area = Rect::new( + area.x, + area.y.saturating_add(u16::from(show_more_above)), + area.width, + area.height + .saturating_sub(u16::from(show_more_above)) + .saturating_sub(u16::from(show_more_below)), + ); + if show_more_above { + frame.render_widget_ref( + more_line("↑ more"), + Rect::new(area.x, area.y, area.width, 1), + ); } - if state.pagination.loading.is_pending() && y < area.y.saturating_add(area.height) { + let start = state.scroll_top.min(rows.len().saturating_sub(1)); + let mut y = content_area.y; + for (idx, row) in rows[start..].iter().enumerate() { + if y >= content_area.y.saturating_add(content_area.height) { + break; + } + let row_idx = start + idx; + let is_selected = row_idx == state.selected; + let is_expanded = + is_selected && row.thread_id.is_some() && state.expanded_thread_id == row.thread_id; + let is_zebra = row_idx.is_multiple_of(2); + for line in render_session_lines(row, state, is_selected, is_expanded, is_zebra, area.width) + { + if y >= content_area.y.saturating_add(content_area.height) { + break; + } + frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1)); + y = y.saturating_add(1); + } + if state.density == SessionListDensity::Comfortable + && y < content_area.y.saturating_add(content_area.height) + && start + idx + 1 < rows.len() + { + y = y.saturating_add(1); + } + } + + if state.pagination.loading.is_pending() + && y < content_area.y.saturating_add(content_area.height) + { let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into(); let rect = Rect::new(area.x, y, area.width, 1); frame.render_widget_ref(loading_line, rect); } + if show_more_below { + let label = if state.pagination.loading.is_pending() { + "↓ loading more" + } else { + "↓ more" + }; + frame.render_widget_ref( + more_line(label), + Rect::new( + area.x, + area.y.saturating_add(area.height.saturating_sub(1)), + area.width, + 1, + ), + ); + } +} + +fn more_line(label: &'static str) -> Line<'static> { + vec![label.dim()].into() +} + +fn render_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + match state.density { + SessionListDensity::Comfortable => { + render_comfortable_session_lines(row, state, is_selected, is_expanded, is_zebra, width) + } + SessionListDensity::Dense => { + render_dense_session_lines(row, state, is_selected, is_expanded, is_zebra, width) + } + } +} + +fn render_comfortable_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + let marker = selection_marker(is_selected, is_expanded); + let title = truncate_text(row.display_preview(), width.saturating_sub(2) as usize); + let title = if is_selected { + selected_session_title_span(title) + } else { + title.into() + }; + let title_line = Line::from(vec![marker, title]); + let mut lines = vec![title_line]; + let row_style = if is_selected { + Some(dense_selected_style()) + } else if is_zebra { + Some(dense_zebra_style()) + } else { + None + }; + if let Some(style) = row_style { + lines = apply_session_row_background(lines, style, width); + } + if is_expanded { + lines.extend(render_transcript_preview_lines(row, state, width)); + return lines; + } + + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let created = format_relative_time(reference, row.created_at); + let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); + let branch = row.git_branch.as_deref(); + let cwd = row + .cwd + .as_ref() + .map(|path| format_directory_display(path, /*max_width*/ None)); + let footer_lines = render_footer_lines( + state.sort_key, + &created, + &updated, + branch, + cwd.as_deref(), + state.filter_mode == SessionFilterMode::All, + width, + ); + if let Some(style) = row_style { + lines.extend(apply_session_row_background(footer_lines, style, width)); + } else { + lines.extend(footer_lines); + } + lines +} + +fn apply_session_row_background( + lines: Vec>, + style: Style, + width: u16, +) -> Vec> { + lines + .into_iter() + .map(|line| apply_line_background(line, style, width)) + .collect() +} + +fn apply_line_background(mut line: Line<'static>, style: Style, width: u16) -> Line<'static> { + let padding = (width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans.push(" ".repeat(padding).set_style(style)); + } + line.style = line.style.patch(style); + for span in &mut line.spans { + span.style = span.style.patch(style); + } + line +} + +fn render_dense_session_lines( + row: &Row, + state: &PickerState, + is_selected: bool, + is_expanded: bool, + is_zebra: bool, + width: u16, +) -> Vec> { + let marker = selection_marker(is_selected, is_expanded); + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let created = format_relative_time(reference, row.created_at); + let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); + let date = match state.sort_key { + ThreadSortKey::CreatedAt => created, + ThreadSortKey::UpdatedAt => updated, + }; + let mut lines = vec![dense_summary_line(DenseSummaryInput { + marker, + date: &date, + title: row.display_preview(), + is_selected, + is_zebra, + width, + })]; + if is_expanded { + lines.extend(render_transcript_preview_lines(row, state, width)); + } + lines +} + +struct DenseSummaryInput<'a> { + marker: Span<'static>, + date: &'a str, + title: &'a str, + is_selected: bool, + is_zebra: bool, + width: u16, +} + +fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> { + let marker_width = input.marker.width(); + let available = (input.width as usize).saturating_sub(marker_width); + let columns = dense_columns(available); + let title = if input.is_selected { + selected_session_title_span(dense_column_text(input.title, columns.title_width)) + } else { + dense_column_text(input.title, columns.title_width).into() + }; + + let spans = vec![ + input.marker, + dense_column_text(input.date, columns.date_width).dim(), + title, + ]; + let mut line = Line::from(spans); + if input.is_selected { + let padding = (input.width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans + .push(" ".repeat(padding).set_style(dense_selected_style())); + } + line = line.style(dense_selected_style()); + } else if input.is_zebra { + let padding = (input.width as usize).saturating_sub(line.width()); + if padding > 0 { + line.spans + .push(" ".repeat(padding).set_style(dense_zebra_style())); + } + line = line.style(dense_zebra_style()); + } + line +} + +struct DenseColumns { + date_width: usize, + title_width: usize, +} + +fn dense_columns(width: usize) -> DenseColumns { + let date_width = SESSION_META_DATE_WIDTH; + DenseColumns { + date_width, + title_width: width.saturating_sub(date_width), + } +} + +fn dense_zebra_style() -> Style { + dense_row_background_style(/*selected*/ false) +} + +fn dense_selected_style() -> Style { + selected_session_style().patch(dense_row_background_style(/*selected*/ true)) +} + +fn dense_row_background_style(selected: bool) -> Style { + let Some(bg) = default_bg() else { + return Style::default(); + }; + let (overlay, alpha) = if is_light(bg) { + ((0, 0, 0), if selected { 0.12 } else { 0.04 }) + } else { + ((255, 255, 255), if selected { 0.12 } else { 0.055 }) + }; + Style::default().bg(best_color(blend(overlay, bg, alpha))) +} + +fn dense_column_text(text: &str, width: usize) -> String { + let text = truncate_text(text, width.saturating_sub(1)); + let padding = width.saturating_sub(UnicodeWidthStr::width(text.as_str())); + format!("{text}{}", " ".repeat(padding)) +} + +fn selection_marker(is_selected: bool, is_expanded: bool) -> Span<'static> { + match (is_selected, is_expanded) { + (true, true) => "⌄ ".set_style(selected_session_style().bold()), + (true, false) => "❯ ".set_style(selected_session_style().bold()), + (false, _) => " ".into(), + } +} + +fn selected_session_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Magenta) + } else { + Style::default().fg(Color::Yellow) + } +} + +fn selected_session_title_span(title: String) -> Span<'static> { + title.set_style(selected_session_style()) +} + +fn render_footer_lines( + sort_key: ThreadSortKey, + created: &str, + updated: &str, + branch: Option<&str>, + cwd: Option<&str>, + show_cwd: bool, + width: u16, +) -> Vec> { + let date = match sort_key { + ThreadSortKey::CreatedAt => created, + ThreadSortKey::UpdatedAt => updated, + }; + let mut parts = vec![FooterPart::Date(date.to_string())]; + if show_cwd { + parts.push(FooterPart::Cwd(cwd.map(str::to_string))); + } + parts.push(FooterPart::Branch(branch.map(str::to_string))); + pack_footer_parts(parts, width) +} + +enum FooterPart { + Date(String), + Branch(Option), + Cwd(Option), +} + +impl FooterPart { + fn text(&self) -> &str { + match self { + FooterPart::Date(text) => text, + FooterPart::Branch(Some(text)) | FooterPart::Cwd(Some(text)) => text, + FooterPart::Branch(None) => "no branch", + FooterPart::Cwd(None) => "no cwd", + } + } + + fn prefix(&self) -> Option<&'static str> { + match self { + FooterPart::Date(_) => None, + FooterPart::Branch(_) => Some(SESSION_META_BRANCH_ICON), + FooterPart::Cwd(_) => Some(SESSION_META_CWD_ICON), + } + } +} + +fn pack_footer_parts(parts: Vec, width: u16) -> Vec> { + let available_width = width as usize; + if available_width <= SESSION_META_INDENT_WIDTH { + return Vec::new(); + } + let cwd_width = cwd_column_width(available_width); + let all_parts_width = footer_parts_width(&parts, cwd_width); + if all_parts_width <= available_width { + return vec![footer_line(parts, available_width, cwd_width)]; + } + + let mut lines = Vec::with_capacity(parts.len()); + let mut current_parts = Vec::new(); + for part in parts { + let mut candidate_parts = std::mem::take(&mut current_parts); + candidate_parts.push(part); + if candidate_parts.len() > 1 + && footer_parts_width(&candidate_parts, cwd_width) > available_width + { + let previous_parts = candidate_parts + .drain(..candidate_parts.len().saturating_sub(1)) + .collect(); + lines.push(footer_line(previous_parts, available_width, cwd_width)); + } + current_parts = candidate_parts; + } + if !current_parts.is_empty() { + lines.push(footer_line(current_parts, available_width, cwd_width)); + } + lines +} + +fn cwd_column_width(width: usize) -> usize { + let available = width.saturating_sub( + SESSION_META_INDENT_WIDTH + SESSION_META_DATE_WIDTH + 2 * SESSION_META_FIELD_GAP_WIDTH, + ); + (available / 2).clamp(SESSION_META_MIN_CWD_WIDTH, SESSION_META_MAX_CWD_WIDTH) +} + +fn footer_parts_width(parts: &[FooterPart], cwd_width: usize) -> usize { + let content_width: usize = parts + .iter() + .enumerate() + .map(|(idx, part)| footer_part_width(part, idx + 1 < parts.len(), cwd_width)) + .sum(); + SESSION_META_INDENT_WIDTH + content_width +} + +fn footer_part_width(part: &FooterPart, padded: bool, cwd_width: usize) -> usize { + let prefix_width = part.prefix().map_or(0, UnicodeWidthStr::width); + let prefix_gap_width = usize::from(part.prefix().is_some() && !part.text().is_empty()); + let text_width = UnicodeWidthStr::width(part.text()); + let actual_width = prefix_width + prefix_gap_width + text_width; + match part { + FooterPart::Date(_) if padded => SESSION_META_DATE_WIDTH.max(actual_width), + FooterPart::Cwd(_) if padded => cwd_width, + _ => actual_width, + } +} + +fn footer_line(parts: Vec, width: usize, cwd_width: usize) -> Line<'static> { + let mut spans: Vec> = vec![" ".into()]; + let mut remaining_width = width.saturating_sub(SESSION_META_INDENT_WIDTH); + let part_count = parts.len(); + for (idx, part) in parts.into_iter().enumerate() { + if idx > 0 { + let gap_width = SESSION_META_FIELD_GAP_WIDTH.min(remaining_width); + if gap_width > 0 { + spans.push(" ".repeat(gap_width).dim()); + remaining_width = remaining_width.saturating_sub(gap_width); + } + } + let padded = idx + 1 < part_count; + let target_width = match part { + FooterPart::Date(_) if padded => Some(SESSION_META_DATE_WIDTH), + FooterPart::Cwd(_) if padded => Some(cwd_width), + FooterPart::Date(_) | FooterPart::Branch(_) | FooterPart::Cwd(_) => None, + }; + let used_width = push_footer_part(&mut spans, part, target_width, remaining_width); + remaining_width = remaining_width.saturating_sub(used_width); + if let Some(target_width) = target_width { + let padding = target_width.saturating_sub(used_width); + if padding > 0 { + spans.push(" ".repeat(padding).dim()); + remaining_width = remaining_width.saturating_sub(padding); + } + } + } + spans.into() +} + +fn push_footer_part( + spans: &mut Vec>, + part: FooterPart, + target_width: Option, + available_width: usize, +) -> usize { + let text = part.text().to_string(); + let Some(prefix) = part.prefix() else { + let text = truncate_text(&text, available_width); + let width = UnicodeWidthStr::width(text.as_str()); + spans.push(text.dim()); + return width; + }; + + let prefix_width = UnicodeWidthStr::width(prefix); + if available_width <= prefix_width { + let prefix = truncate_text(prefix, available_width); + let width = UnicodeWidthStr::width(prefix.as_str()); + spans.push(prefix.dim()); + return width; + } + + spans.push(prefix.dim()); + let mut used_width = prefix_width; + if !text.is_empty() && used_width < available_width { + spans.push(" ".dim()); + used_width += 1; + } + let text_width = target_width + .unwrap_or(available_width) + .saturating_sub(used_width) + .min(available_width.saturating_sub(used_width)); + let text = truncate_text(&text, text_width); + let rendered_text_width = UnicodeWidthStr::width(text.as_str()); + match part { + FooterPart::Branch(None) | FooterPart::Cwd(None) => spans.push(text.dim().italic()), + _ => spans.push(text.dim()), + } + used_width + rendered_text_width +} + +fn render_transcript_preview_lines( + row: &Row, + state: &PickerState, + width: u16, +) -> Vec> { + let mut details = render_expanded_session_details(row, state, width); + let Some(thread_id) = row.thread_id else { + return details; + }; + let preview_lines = match state.transcript_previews.get(&thread_id) { + Some(TranscriptPreviewState::Loading) => { + vec![vec![" │ ".dim(), "Loading recent transcript...".italic().dim()].into()] + } + Some(TranscriptPreviewState::Failed) => vec![ + vec![ + " │ ".dim(), + "Could not load transcript preview".italic().red(), + ] + .into(), + ], + Some(TranscriptPreviewState::Loaded(lines)) => { + render_conversation_preview_lines(lines, width) + } + None => Vec::new(), + }; + details.extend(preview_lines); + details +} + +fn render_expanded_session_details( + row: &Row, + state: &PickerState, + width: u16, +) -> Vec> { + let reference = state.relative_time_reference.unwrap_or_else(Utc::now); + let session = row + .thread_name + .as_deref() + .map(str::to_string) + .or_else(|| row.thread_id.map(|thread_id| thread_id.to_string())) + .unwrap_or_else(|| "-".to_string()); + let directory = row + .cwd + .as_ref() + .map(|path| format_directory_display(path, /*max_width*/ None)) + .unwrap_or_else(|| "-".to_string()); + let branch = row + .git_branch + .as_ref() + .map(|branch| format!("{SESSION_META_BRANCH_ICON} {branch}")) + .unwrap_or_else(|| format!("{SESSION_META_BRANCH_ICON} no branch")); + + vec![ + expanded_detail_line("Session:", &session, width), + expanded_time_detail_line("Created:", reference, row.created_at, width), + expanded_time_detail_line( + "Updated:", + reference, + row.updated_at.or(row.created_at), + width, + ), + expanded_detail_line("Directory:", &directory, width), + expanded_detail_line("Branch:", &branch, width), + vec![" │".dim()].into(), + vec![" │ ".dim(), "Conversation:".dim()].into(), + ] +} + +fn render_conversation_preview_lines( + lines: &[TranscriptPreviewLine], + width: u16, +) -> Vec> { + if lines.is_empty() { + return vec![ + vec![ + " └ ".dim(), + "No transcript preview available".italic().dim(), + ] + .into(), + ]; + } + + let mut rendered = Vec::new(); + for line in lines { + rendered.extend(render_transcript_content_lines(line, width)); + } + let rendered_len = rendered.len(); + rendered + .into_iter() + .enumerate() + .map(|(idx, line)| { + let prefix = if idx + 1 == rendered_len { + " └ " + } else { + " │ " + }; + prefix_transcript_line(prefix, line) + }) + .collect() +} + +fn render_transcript_content_lines(line: &TranscriptPreviewLine, width: u16) -> Vec> { + let content_width = width.saturating_sub(4) as usize; + let lines = match line.speaker { + TranscriptPreviewSpeaker::User => vec![conversation_content_line( + Line::from(line.text.clone()), + conversation_user_style(), + )], + TranscriptPreviewSpeaker::Assistant => { + let mut lines = Vec::new(); + append_markdown( + &line.text, /*width*/ None, /*cwd*/ None, &mut lines, + ); + for line in &mut lines { + *line = conversation_content_line(line.clone(), conversation_assistant_style()); + } + lines + } + }; + adaptive_wrap_lines(lines, RtOptions::new(content_width.max(/*other*/ 1))) +} + +fn conversation_content_line(mut line: Line<'static>, style: Style) -> Line<'static> { + line.style = line.style.patch(style); + for span in &mut line.spans { + span.style = span.style.patch(style); + } + line +} + +fn prefix_transcript_line(prefix: &'static str, line: Line<'static>) -> Line<'static> { + let mut spans = vec![prefix.set_style(transcript_prefix_style(&line))]; + spans.extend(line.spans); + Line::from(spans).style(line.style) +} + +fn transcript_prefix_style(line: &Line<'_>) -> Style { + let style = line + .spans + .iter() + .find(|span| !span.content.trim().is_empty()) + .map(|span| line.style.patch(span.style)) + .unwrap_or(line.style); + connector_style_from_content(style) +} + +fn connector_style_from_content(style: Style) -> Style { + Style { + fg: style.fg, + bg: style.bg, + ..Style::default() + } +} + +fn conversation_assistant_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Gray) + } else { + Style::default().fg(Color::DarkGray) + } +} + +fn conversation_user_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::DarkGray).italic() + } else { + Style::default().fg(Color::Gray).italic() + } +} + +fn expanded_detail_line(label: &'static str, value: &str, width: u16) -> Line<'static> { + const LABEL_WIDTH: usize = 10; + let prefix_width = 4; + let gap_width = 2; + let value_width = (width as usize) + .saturating_sub(prefix_width + LABEL_WIDTH + gap_width) + .max(1); + vec![ + " │ ".dim(), + format!("{label:, + ts: Option>, + width: u16, +) -> Line<'static> { + let Some(ts) = ts else { + return expanded_detail_line(label, "-", width); + }; + let value = format!( + "{} · {}", + format_relative_time_long(reference, ts), + format_timestamp(ts) + ); + expanded_detail_line(label, &value, width) +} + +fn format_relative_time(reference: DateTime, ts: Option>) -> String { + let Some(ts) = ts else { + return "-".to_string(); + }; + let seconds = (reference - ts).num_seconds().max(0); + if seconds == 0 { + return "now".to_string(); + } + if seconds < 60 { + return format!("{seconds}s ago"); + } + let minutes = seconds / 60; + if minutes < 60 { + return format!("{minutes}m ago"); + } + let hours = minutes / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let days = hours / 24; + format!("{days}d ago") +} + +fn format_relative_time_long(reference: DateTime, ts: DateTime) -> String { + let seconds = (reference - ts).num_seconds().max(0); + if seconds == 0 { + return "now".to_string(); + } + if seconds < 60 { + return plural_time(seconds, "second"); + } + let minutes = seconds / 60; + if minutes < 60 { + return plural_time(minutes, "minute"); + } + let hours = minutes / 60; + if hours < 24 { + return plural_time(hours, "hour"); + } + plural_time(hours / 24, "day") +} + +fn plural_time(value: i64, unit: &str) -> String { + if value == 1 { + format!("1 {unit} ago") + } else { + format!("{value} {unit}s ago") + } +} + +fn format_timestamp(ts: DateTime) -> String { + ts.format("%Y-%m-%d %H:%M:%S").to_string() } fn render_empty_state_line(state: &PickerState) -> Line<'static> { @@ -1264,254 +3210,11 @@ fn render_empty_state_line(state: &PickerState) -> Line<'static> { vec!["No sessions yet".italic().dim()].into() } -fn human_time_ago(ts: DateTime, reference_now: DateTime) -> String { - let delta = reference_now - ts; - let secs = delta.num_seconds(); - if secs < 60 { - let n = secs.max(0); - if n == 1 { - format!("{n} second ago") - } else { - format!("{n} seconds ago") - } - } else if secs < 60 * 60 { - let m = secs / 60; - if m == 1 { - format!("{m} minute ago") - } else { - format!("{m} minutes ago") - } - } else if secs < 60 * 60 * 24 { - let h = secs / 3600; - if h == 1 { - format!("{h} hour ago") - } else { - format!("{h} hours ago") - } - } else { - let d = secs / (60 * 60 * 24); - if d == 1 { - format!("{d} day ago") - } else { - format!("{d} days ago") - } - } -} - -fn format_updated_label_at(row: &Row, reference_now: DateTime) -> String { - match (row.updated_at, row.created_at) { - (Some(updated), _) => human_time_ago(updated, reference_now), - (None, Some(created)) => human_time_ago(created, reference_now), - (None, None) => "-".to_string(), - } -} - -fn format_created_label_at(row: &Row, reference_now: DateTime) -> String { - match row.created_at { - Some(created) => human_time_ago(created, reference_now), - None => "-".to_string(), - } -} - -fn render_column_headers( - frame: &mut crate::custom_terminal::Frame, - area: Rect, - metrics: &ColumnMetrics, - sort_key: ThreadSortKey, -) { - if area.height == 0 { - return; - } - - let mut spans: Vec = vec![" ".into()]; - let visibility = column_visibility(area.width, metrics, sort_key); - if visibility.show_created { - let label = format!( - "{text:, -} - -/// Determines which columns to render given available terminal width. -/// -/// When the terminal is narrow, only one timestamp column is shown (whichever -/// matches the current sort key). Branch and CWD are hidden if their max -/// widths are zero (no data to show). -#[derive(Debug, PartialEq, Eq)] -struct ColumnVisibility { - show_created: bool, - show_updated: bool, - show_branch: bool, - show_cwd: bool, -} - -fn calculate_column_metrics( - rows: &[Row], - include_cwd: bool, - reference_now: DateTime, -) -> ColumnMetrics { - fn right_elide(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); - } - if max <= 1 { - return "…".to_string(); - } - let tail_len = max - 1; - let tail: String = s - .chars() - .rev() - .take(tail_len) - .collect::() - .chars() - .rev() - .collect(); - format!("…{tail}") - } - - let mut labels: Vec<(String, String, String, String)> = Vec::with_capacity(rows.len()); - let mut max_created_width = UnicodeWidthStr::width(CREATED_COLUMN_LABEL); - let mut max_updated_width = UnicodeWidthStr::width(UPDATED_COLUMN_LABEL); - let mut max_branch_width = UnicodeWidthStr::width("Branch"); - let mut max_cwd_width = if include_cwd { - UnicodeWidthStr::width("CWD") - } else { - 0 - }; - - for row in rows { - let created = format_created_label_at(row, reference_now); - let updated = format_updated_label_at(row, reference_now); - let branch_raw = row.git_branch.clone().unwrap_or_default(); - let branch = right_elide(&branch_raw, /*max*/ 24); - let cwd = if include_cwd { - let cwd_raw = row - .cwd - .as_ref() - .map(|p| display_path_for(p, std::path::Path::new("/"))) - .unwrap_or_default(); - right_elide(&cwd_raw, /*max*/ 24) - } else { - String::new() - }; - max_created_width = max_created_width.max(UnicodeWidthStr::width(created.as_str())); - max_updated_width = max_updated_width.max(UnicodeWidthStr::width(updated.as_str())); - max_branch_width = max_branch_width.max(UnicodeWidthStr::width(branch.as_str())); - max_cwd_width = max_cwd_width.max(UnicodeWidthStr::width(cwd.as_str())); - labels.push((created, updated, branch, cwd)); - } - - ColumnMetrics { - max_created_width, - max_updated_width, - max_branch_width, - max_cwd_width, - labels, - } -} - -/// Computes which columns fit in the available width. -/// -/// The algorithm reserves at least `MIN_PREVIEW_WIDTH` characters for the -/// conversation preview. If both timestamp columns don't fit, only the one -/// matching the current sort key is shown. -fn column_visibility( - area_width: u16, - metrics: &ColumnMetrics, - sort_key: ThreadSortKey, -) -> ColumnVisibility { - const MIN_PREVIEW_WIDTH: usize = 10; - - let show_branch = metrics.max_branch_width > 0; - let show_cwd = metrics.max_cwd_width > 0; - - // Calculate remaining width after all optional columns. - let mut preview_width = area_width as usize; - preview_width = preview_width.saturating_sub(2); // marker - if metrics.max_created_width > 0 { - preview_width = preview_width.saturating_sub(metrics.max_created_width + 2); - } - if metrics.max_updated_width > 0 { - preview_width = preview_width.saturating_sub(metrics.max_updated_width + 2); - } - if show_branch { - preview_width = preview_width.saturating_sub(metrics.max_branch_width + 2); - } - if show_cwd { - preview_width = preview_width.saturating_sub(metrics.max_cwd_width + 2); - } - - // If preview would be too narrow, hide the non-active timestamp column. - let show_both = preview_width >= MIN_PREVIEW_WIDTH; - let show_created = if show_both { - metrics.max_created_width > 0 - } else { - sort_key == ThreadSortKey::CreatedAt - }; - let show_updated = if show_both { - metrics.max_updated_width > 0 - } else { - sort_key == ThreadSortKey::UpdatedAt - }; - - ColumnVisibility { - show_created, - show_updated, - show_branch, - show_cwd, - } -} - #[cfg(test)] mod tests { use super::*; use chrono::Duration; + use codex_config::CONFIG_TOML_FILE; use codex_protocol::ThreadId; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -1525,6 +3228,7 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; + use tempfile::tempdir; fn page( rows: Vec, @@ -1540,6 +3244,14 @@ mod tests { } } + fn page_only_loader(loader: impl Fn(PageLoadRequest) + Send + Sync + 'static) -> PickerLoader { + Arc::new(move |request| { + if let PickerLoadRequest::Page(request) = request { + loader(request); + } + }) + } + fn make_row(path: &str, ts: &str, preview: &str) -> Row { let timestamp = parse_timestamp_str(ts).expect("timestamp should parse"); Row { @@ -1554,6 +3266,38 @@ mod tests { } } + fn footer_lines_text(state: &PickerState, width: u16) -> String { + footer_hint_lines(state, width) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n") + } + + fn footer_snapshot(state: &PickerState, width: u16, list_height: u16) -> String { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let backend = VT100Backend::new(width, /*height*/ 4); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 4)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_picker_footer(&mut frame, area, state, list_height); + } + terminal.flush().expect("flush"); + + terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + } + #[test] fn row_display_preview_prefers_thread_name() { let row = Row { @@ -1592,6 +3336,213 @@ mod tests { ); } + #[test] + fn row_search_matches_metadata_fields() { + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("first message"), + thread_id: Some(thread_id), + thread_name: Some(String::from("My session")), + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/tmp/codex-session-picker")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + + assert!(row.matches_query("session-picker")); + assert!(row.matches_query("fcoury")); + assert!(row.matches_query(&thread_id.to_string()[..8])); + } + + #[test] + fn relative_time_formats_zero_seconds_as_now() { + let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z") + .expect("valid timestamp") + .with_timezone(&Utc); + + assert_eq!(format_relative_time(reference, Some(reference)), "now"); + assert_eq!( + format_relative_time(reference, Some(reference - Duration::seconds(1))), + "1s ago" + ); + } + + #[test] + fn long_relative_time_uses_words() { + let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z") + .expect("valid timestamp") + .with_timezone(&Utc); + + assert_eq!(format_relative_time_long(reference, reference), "now"); + assert_eq!( + format_relative_time_long(reference, reference - Duration::minutes(20)), + "20 minutes ago" + ); + assert_eq!( + format_relative_time_long(reference, reference - Duration::hours(1)), + "1 hour ago" + ); + } + + #[test] + fn expanded_session_details_include_metadata() { + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = parse_timestamp_str("2026-05-02T14:48:19Z"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("first message"), + thread_id: Some(thread_id), + thread_name: Some(String::from("feat(tui): add raw scrollback mode")), + created_at: parse_timestamp_str("2026-05-02T14:31:08Z"), + updated_at: parse_timestamp_str("2026-05-02T14:48:19Z"), + cwd: Some(PathBuf::from("/Users/felipe.coury/code/codex")), + git_branch: Some(String::from("codex/raw-scrollback-mode")), + }; + + let rendered = render_expanded_session_details(&row, &state, /*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + let expected_directory = + format_directory_display(row.cwd.as_deref().expect("cwd"), /*max_width*/ None); + + assert!(rendered.contains("Session: feat(tui): add raw scrollback mode")); + assert!(rendered.contains("Created: 17 minutes ago · 2026-05-02 14:31:08")); + assert!(rendered.contains("Updated: now · 2026-05-02 14:48:19")); + assert!(rendered.contains(&format!("Directory: {expected_directory}"))); + assert!(rendered.contains("Branch:  codex/raw-scrollback-mode")); + assert!(rendered.contains("Conversation:")); + } + + #[test] + fn footer_prioritizes_active_sort_timestamp() { + let updated = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "3h ago", + Some("main"), + Some("tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + let created = render_footer_lines( + ThreadSortKey::CreatedAt, + "5h ago", + "3h ago", + Some("main"), + Some("tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(updated.len(), 1); + assert_eq!(created.len(), 1); + assert!(updated[0].to_string().starts_with(" 3h ago")); + assert!(created[0].to_string().starts_with(" 5h ago")); + assert!(!updated[0].to_string().contains("created 5h ago")); + assert!(!created[0].to_string().contains("updated 3h ago")); + assert_metadata_order(&updated[0], "⌁ tmp/codex", " main"); + assert_metadata_order(&created[0], "⌁ tmp/codex", " main"); + } + + #[test] + fn footer_marks_missing_branch() { + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "3h ago", + /*branch*/ None, + Some("/tmp/codex"), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let rendered = footer[0].to_string(); + assert!(rendered.contains("⌁ /tmp/codex")); + assert!(rendered.contains(" no branch")); + assert_metadata_order(&footer[0], "⌁ /tmp/codex", " no branch"); + } + + #[test] + fn footer_branch_expands_when_line_has_room() { + let branch = "etraut/animations-false-improvements"; + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some(branch), + Some("~/code/codex.etraut-animations-false-improvements/codex-rs"), + /*show_cwd*/ true, + /*width*/ 140, + ); + + assert_eq!(footer.len(), 1); + assert!(footer[0].to_string().contains(branch)); + } + + #[test] + fn footer_cwd_truncates_to_responsive_column() { + let cwd = "~/code/codex.owner-extremely-long-worktree-name-that-needs-truncating/codex-rs"; + let branch = "owner/branch"; + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some(branch), + Some(cwd), + /*show_cwd*/ true, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let footer = footer[0].to_string(); + assert!(!footer.contains(cwd)); + assert!(footer.contains("⌁ ~/code/codex.")); + assert!(footer.contains("...")); + assert!(footer.contains(" owner/branch")); + } + + #[test] + fn footer_omits_cwd_when_hidden() { + let footer = render_footer_lines( + ThreadSortKey::UpdatedAt, + "5h ago", + "4h ago", + Some("owner/branch"), + Some("~/code/codex.owner-worktree/codex-rs"), + /*show_cwd*/ false, + /*width*/ 80, + ); + + assert_eq!(footer.len(), 1); + let footer = footer[0].to_string(); + assert!(footer.contains("4h ago")); + assert!(footer.contains(" owner/branch")); + assert!(!footer.contains("⌁")); + assert!(!footer.contains("~/code")); + } + + fn assert_metadata_order(line: &Line<'_>, first: &str, second: &str) { + let rendered = line.to_string(); + let first_index = rendered.find(first).expect("first metadata item"); + let second_index = rendered.find(second).expect("second metadata item"); + assert!(first_index < second_index); + } + #[test] fn remote_thread_list_params_omit_provider_filter() { let params = thread_list_params( @@ -1629,9 +3580,49 @@ mod tests { assert_eq!(params.source_kinds, None); } + #[test] + fn remote_picker_sends_cwd_filter_without_local_post_filtering() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + let remote_cwd = Some(PathBuf::from("/srv/link-project")); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::Any, + /*show_all*/ false, + remote_cwd.clone(), + SessionPickerAction::Resume, + ); + state.local_filter_cwd = local_picker_cwd_filter(&remote_cwd, /*is_remote*/ true); + + state.start_initial_load(); + + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, remote_cwd); + } + + let row = Row { + path: None, + preview: String::from("remote session"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from("/srv/real-project")), + git_branch: None, + }; + + assert!(state.row_matches_filter(&row)); + } + #[test] fn remote_picker_does_not_filter_rows_by_local_cwd() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1658,10 +3649,8 @@ mod tests { fn resume_table_snapshot() { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; - use ratatui::layout::Constraint; - use ratatui::layout::Layout; - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1671,7 +3660,7 @@ mod tests { SessionPickerAction::Resume, ); - let now = Utc::now(); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); let rows = vec![ Row { path: Some(PathBuf::from("/tmp/a.jsonl")), @@ -1706,16 +3695,13 @@ mod tests { ]; state.all_rows = rows.clone(); state.filtered_rows = rows; - state.view_rows = Some(3); + state.relative_time_reference = Some(now); state.selected = 1; state.scroll_top = 0; - state.update_view_rows(/*rows*/ 3); - - state.relative_time_reference = Some(now); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now); + state.update_viewport(/*rows*/ 12, /*width*/ 80); let width: u16 = 80; - let height: u16 = 6; + let height: u16 = 12; let backend = VT100Backend::new(width, height); let mut terminal = Terminal::with_options(backend).expect("terminal"); terminal.set_viewport_area(Rect::new(0, 0, width, height)); @@ -1723,10 +3709,7 @@ mod tests { { let mut frame = terminal.get_frame(); let area = frame.area(); - let segments = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area); - render_column_headers(&mut frame, segments[0], &metrics, state.sort_key); - render_list(&mut frame, segments[1], &state, &metrics); + render_list(&mut frame, area, &state); } terminal.flush().expect("flush"); @@ -1739,7 +3722,7 @@ mod tests { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1760,7 +3743,7 @@ mod tests { { let mut frame = terminal.get_frame(); - let line = search_line(&state); + let line = search_line(&state, frame.area().width); frame.render_widget_ref(line, frame.area()); } terminal.flush().expect("flush"); @@ -1769,9 +3752,1443 @@ mod tests { assert_snapshot!("resume_picker_search_error", snapshot); } + #[test] + fn hint_line_switches_esc_label_for_search_mode() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc start new")); + + state.query = String::from("picker"); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc clear search")); + } + + #[test] + fn hint_line_labels_cancel_keys_as_exit_for_existing_session_resume_picker() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.launch_context = SessionPickerLaunchContext::ExistingSession; + + let wide = footer_lines_text(&state, /*width*/ 220); + assert!(wide.contains("esc exit")); + assert!(wide.contains("ctrl+c exit")); + + let compact = footer_lines_text(&state, /*width*/ 119); + assert!(compact.contains("esc exit")); + assert!(compact.contains("ctrl+c exit")); + + state.query = String::from("picker"); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("esc clear search")); + } + + #[test] + fn hint_line_switches_density_label() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o dense view")); + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+t transcript")); + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+e expand")); + + state.density = SessionListDensity::Dense; + + assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o comfortable view")); + } + + #[test] + fn hint_line_compacts_on_narrow_width() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let rendered = footer_lines_text(&state, /*width*/ 119); + + assert!(rendered.contains("esc new")); + assert!(rendered.contains("tab focus")); + assert!(rendered.contains("←/→ option")); + assert!(rendered.contains("ctrl+o dense")); + assert!(rendered.contains("ctrl+t preview")); + assert!(rendered.contains("ctrl+e exp")); + assert!(!rendered.contains("focus sort/filter")); + } + + #[test] + fn hint_line_snapshot_uses_distributed_wide_footer() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_snapshot!( + "resume_picker_footer_wide", + footer_snapshot(&state, /*width*/ 220, /*list_height*/ 20) + ); + } + + #[test] + fn hint_line_snapshot_uses_compact_footer() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("picker"); + state.density = SessionListDensity::Dense; + + assert_snapshot!( + "resume_picker_footer_compact", + footer_snapshot(&state, /*width*/ 96, /*list_height*/ 20) + ); + } + + #[test] + fn hint_line_prioritizes_keybinds_when_very_narrow() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + + let width = 38; + let lines = footer_hint_lines(&state, width); + let rendered = lines + .iter() + .map(Line::to_string) + .collect::>() + .join("\n"); + + assert!(lines.iter().all(|line| line.width() <= width as usize)); + assert!(rendered.contains("enter")); + assert!(rendered.contains("esc")); + assert!(rendered.contains("ctrl+c")); + assert!(rendered.contains("ctrl+o")); + assert!(rendered.contains("ctrl+t")); + assert!(rendered.contains("ctrl+e")); + assert!(rendered.contains("↑/↓")); + } + + #[test] + fn hint_line_shows_loading_transcript_mode() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(ThreadId::new()); + + let rendered = footer_lines_text(&state, /*width*/ 80); + + assert!(rendered.contains("loading transcript")); + assert!(rendered.contains("ctrl+c quit")); + assert!(!rendered.contains("enter")); + } + + #[test] + fn picker_footer_percent_reports_scroll_progress() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + + state.scroll_top = 0; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 6), 0); + + state.scroll_top = state.filtered_rows.len() - 1; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 6), 100); + } + + #[test] + fn picker_footer_progress_label_shows_position_total_and_percent() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 2; + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 3 / 10 · 0% "); + assert!(!label.contains('-')); + } + + #[test] + fn picker_footer_progress_label_uses_known_count_when_more_pages_exist() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 2; + state.pagination.next_cursor = Some(PageCursor::AppServer(String::from("cursor-1"))); + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 3 / 10 · 0% "); + } + + #[test] + fn picker_footer_progress_label_freezes_percent_while_loading() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.selected = 9; + state.scroll_top = 9; + state.pagination.next_cursor = Some(PageCursor::AppServer(String::from("cursor-1"))); + state.pagination.loading = LoadingState::Pending(PendingLoad { + request_token: 1, + search_token: None, + }); + state.frozen_footer_percent = Some(37); + + let label = picker_footer_progress_label(&state, /*list_height*/ 6, /*width*/ 80); + + assert_eq!(label, " 10 / 10… · 37% "); + } + + #[test] + fn picker_footer_percent_is_complete_when_not_scrollable() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_eq!(picker_footer_percent(&state, /*list_height*/ 20), 100); + + state.filtered_rows = vec![make_row( + "/tmp/1.jsonl", + "2026-05-02T12:00:00Z", + "single row", + )]; + assert_eq!(picker_footer_percent(&state, /*list_height*/ 20), 100); + } + + #[tokio::test] + async fn ctrl_o_toggles_density_without_typing_into_search() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("pick"); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert_eq!(state.query, "pick"); + } + + #[tokio::test] + async fn ctrl_t_requests_selected_session_transcript() { + let thread_id = ThreadId::new(); + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Transcript { thread_id } = request { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Comfortable); + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + assert!(matches!( + state.transcript_cells.get(&thread_id), + Some(SessionTranscriptState::Loading) + )); + } + + #[tokio::test] + async fn transcript_loading_consumes_picker_input() { + let loader = page_only_loader(|_| {}); + let thread_id = ThreadId::new(); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![ + Row { + path: None, + preview: String::from("one"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + Row { + path: None, + preview: String::from("two"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + ]; + state.pending_transcript_open = Some(thread_id); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + + assert!(selection.is_none()); + assert_eq!(state.selected, 0); + assert_eq!(state.query, ""); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert!(selection.is_none()); + assert_eq!(state.query, ""); + } + + #[tokio::test] + async fn transcript_loading_still_allows_ctrl_c_exit() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(ThreadId::new()); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert!(matches!(selection, Some(SessionSelection::Exit))); + } + + #[test] + fn transcript_loading_overlay_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let thread_id = ThreadId::new(); + state.pending_transcript_open = Some(thread_id); + state.filtered_rows = vec![ + Row { + path: None, + preview: String::from("Find pending threads and emails"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + Row { + path: None, + preview: String::from("Plan raw scrollback mode"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }, + ]; + state.update_viewport(/*rows*/ 7, /*width*/ 80); + + let width: u16 = 80; + let height: u16 = 7; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + render_transcript_loading_overlay(&mut frame, area); + } + terminal.flush().expect("flush"); + + let snapshot = terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + assert_snapshot!("resume_picker_transcript_loading_overlay", snapshot); + } + + #[tokio::test] + async fn raw_ctrl_t_requests_selected_session_transcript() { + let thread_id = ThreadId::new(); + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Transcript { thread_id } = request { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{0014}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + } + + #[tokio::test] + async fn ctrl_t_on_row_without_thread_id_shows_inline_error() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("preview"), + thread_id: None, + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!( + state.inline_error.as_deref(), + Some("No transcript available for this session") + ); + } + + #[tokio::test] + async fn loaded_transcript_waits_for_loading_frame_before_opening_overlay() { + use crate::history_cell::PlainHistoryCell; + + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.pending_transcript_open = Some(thread_id); + let cells: TranscriptCells = + vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))]; + + state + .handle_background_event(BackgroundEvent::Transcript { + thread_id, + transcript: Ok(cells), + }) + .await + .unwrap(); + + assert!(state.overlay.is_none()); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + assert!(matches!( + state.transcript_cells.get(&thread_id), + Some(SessionTranscriptState::Loaded(_)) + )); + + assert!(state.note_transcript_loading_frame_drawn()); + state.open_pending_transcript_if_ready(); + + assert!(matches!(state.overlay, Some(Overlay::Transcript(_)))); + assert_eq!(state.pending_transcript_open, None); + } + + #[tokio::test] + async fn cached_transcript_still_shows_loading_frame_before_opening_overlay() { + use crate::history_cell::PlainHistoryCell; + + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + state.transcript_cells.insert( + thread_id, + SessionTranscriptState::Loaded(vec![Arc::new(PlainHistoryCell::new(vec![ + "transcript".into(), + ]))]), + ); + + state + .handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert!(state.overlay.is_none()); + assert_eq!(state.pending_transcript_open, Some(thread_id)); + + assert!(state.note_transcript_loading_frame_drawn()); + state.open_pending_transcript_if_ready(); + + assert!(matches!(state.overlay, Some(Overlay::Transcript(_)))); + assert_eq!(state.pending_transcript_open, None); + } + + #[tokio::test] + async fn ctrl_o_persists_density_preference() { + let tmp = tempdir().expect("tmpdir"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: tmp.path().to_path_buf(), + active_profile: None, + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!( + contents, + r#"[tui] +session_picker_view = "dense" +"# + ); + } + + #[tokio::test] + async fn ctrl_o_persists_density_preference_for_active_profile() { + let tmp = tempdir().expect("tmpdir"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: tmp.path().to_path_buf(), + active_profile: Some(String::from("work")), + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!( + contents, + r#"[profiles.work.tui] +session_picker_view = "dense" +"# + ); + } + + #[tokio::test] + async fn ctrl_o_keeps_toggled_density_when_persistence_fails() { + let tmp = tempdir().expect("tmpdir"); + let codex_home_file = tmp.path().join("codex-home-file"); + std::fs::write(&codex_home_file, "not a directory").expect("write codex home file"); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.view_persistence = Some(SessionPickerViewPersistence { + codex_home: codex_home_file, + active_profile: None, + }); + + state + .handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert!( + state + .inline_error + .as_deref() + .is_some_and(|error| error.contains("Failed to save view mode")), + "expected persistence error, got {:?}", + state.inline_error + ); + } + + #[tokio::test] + async fn raw_ctrl_o_toggles_density_without_typing_into_search() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("pick"); + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{000f}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.density, SessionListDensity::Dense); + assert_eq!(state.query, "pick"); + } + + #[tokio::test] + async fn space_appends_to_search_query() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.query = String::from("resize"); + + state + .handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)) + .await + .unwrap(); + state + .handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.query, "resize r"); + assert_eq!(state.expanded_thread_id, None); + } + + #[tokio::test] + async fn ctrl_e_toggles_selected_session_expansion() { + let thread_id = ThreadId::new(); + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader: PickerLoader = Arc::new(move |request| { + if let PickerLoadRequest::Preview { thread_id } = request { + request_sink.lock().unwrap().push(thread_id); + } + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, Some(thread_id)); + assert_eq!(*recorded_requests.lock().unwrap(), vec![thread_id]); + + state + .handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, None); + } + + #[tokio::test] + async fn raw_ctrl_e_toggles_selected_session_expansion() { + let thread_id = ThreadId::new(); + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.filtered_rows = vec![Row { + path: None, + preview: String::from("preview"), + thread_id: Some(thread_id), + thread_name: None, + created_at: None, + updated_at: None, + cwd: None, + git_branch: None, + }]; + + state + .handle_key(KeyEvent::new(KeyCode::Char('\u{0005}'), KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.expanded_thread_id, Some(thread_id)); + } + + #[test] + fn search_line_renders_sort_and_filter_tabs() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + let width: u16 = 100; + let backend = VT100Backend::new(width, /*height*/ 1); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 1)); + + { + let mut frame = terminal.get_frame(); + let line = search_line(&state, frame.area().width); + frame.render_widget_ref(line, frame.area()); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_search_line_sort_filter_tabs", + terminal.backend().to_string() + ); + } + + #[test] + fn search_line_compacts_toolbar_on_narrow_width() { + let loader = page_only_loader(|_| {}); + let state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + let line = search_line(&state, /*width*/ 40).to_string(); + + assert!(line.contains("Filter:[Cwd]")); + assert!(line.contains("Sort:[Updated]")); + assert!(line.find("Filter:[Cwd]") < line.find("Sort:[Updated]")); + } + + fn dense_snapshot_row() -> Row { + Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from( + "Propose session picker redesign with enough title text to exercise truncation", + ), + thread_id: Some( + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"), + ), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs", + )), + git_branch: Some(String::from("fcoury/session-picker")), + } + } + + fn render_dense_row_snapshot( + show_all: bool, + filter_cwd: Option, + width: u16, + ) -> String { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let row = dense_snapshot_row(); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + show_all, + filter_cwd, + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let backend = VT100Backend::new(width, /*height*/ 3); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, 3)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + terminal.backend().to_string() + } + + #[test] + fn dense_session_snapshot_omits_cwd_in_cwd_filter() { + assert_snapshot!( + "resume_picker_dense_cwd", + render_dense_row_snapshot( + /*show_all*/ false, + Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs" + )), + /*width*/ 100, + ) + ); + } + + #[test] + fn dense_session_snapshot_includes_cwd_in_all_filter() { + assert_snapshot!( + "resume_picker_dense_all", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 120, + ) + ); + } + + #[test] + fn dense_session_snapshot_auto_hides_cwd_when_narrow() { + assert_snapshot!( + "resume_picker_dense_all_auto_hidden_cwd", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 100, + ) + ); + } + + #[test] + fn dense_session_snapshot_forces_cwd_when_narrow() { + assert_snapshot!( + "resume_picker_dense_all_forced_cwd", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 48, + ) + ); + } + + #[test] + fn dense_session_snapshot_drops_metadata_when_narrow() { + assert_snapshot!( + "resume_picker_dense_narrow", + render_dense_row_snapshot( + /*show_all*/ true, /*filter_cwd*/ None, /*width*/ 48, + ) + ); + } + + #[test] + fn dense_session_line_prefers_thread_name_over_preview() { + let mut row = dense_snapshot_row(); + row.preview = String::from("Raw conversation preview"); + row.thread_name = Some(String::from("Named session")); + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let rendered = render_dense_session_lines( + &row, &state, /*is_selected*/ false, /*is_expanded*/ false, + /*is_zebra*/ false, /*width*/ 100, + ) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("Named session")); + assert!(!rendered.contains("Raw conversation preview")); + } + + #[test] + fn dense_selected_summary_line_uses_full_width_selection_style() { + let line = dense_summary_line(DenseSummaryInput { + marker: selection_marker(/*is_selected*/ true, /*is_expanded*/ false), + date: "15m ago", + title: "Selected dense row", + is_selected: true, + is_zebra: false, + width: 80, + }); + + assert_eq!(line.width(), 80); + assert_eq!(line.style.fg, selected_session_style().fg); + assert_eq!(line.spans[0].content, "❯ "); + } + + #[test] + fn dense_zebra_summary_line_uses_full_width_background() { + let line = dense_summary_line(DenseSummaryInput { + marker: selection_marker(/*is_selected*/ false, /*is_expanded*/ false), + date: "15m ago", + title: "Zebra dense row", + is_selected: false, + is_zebra: true, + width: 80, + }); + + assert_eq!(line.width(), 80); + assert_eq!(line.style.bg, dense_zebra_style().bg); + } + + #[test] + fn comfortable_zebra_lines_use_full_width_background() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.relative_time_reference = + Some(parse_timestamp_str("2026-05-02T12:00:00Z").expect("timestamp")); + let row = make_row( + "/tmp/a.jsonl", + "2026-05-02T11:45:00Z", + "Zebra comfortable row", + ); + + let lines = render_comfortable_session_lines( + &row, &state, /*is_selected*/ false, /*is_expanded*/ false, + /*is_zebra*/ true, /*width*/ 100, + ); + + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|line| line.width() == 100)); + assert!( + lines + .iter() + .all(|line| line.style.bg == dense_zebra_style().bg) + ); + } + + #[test] + fn dense_session_snapshot_uses_no_blank_lines_between_rows() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut first = dense_snapshot_row(); + first.preview = String::from("First dense row"); + let mut second = dense_snapshot_row(); + second.preview = String::from("Second dense row"); + second.git_branch = Some(String::from("fcoury/other-branch")); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from( + "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs", + )), + SessionPickerAction::Resume, + ); + state.density = SessionListDensity::Dense; + state.all_rows = vec![first.clone(), second.clone()]; + state.filtered_rows = vec![first, second]; + state.selected = 1; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let backend = VT100Backend::new(/*width*/ 80, /*height*/ 2); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 80, 2)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_dense_no_blank_lines", + terminal.backend().to_string() + ); + } + + #[test] + fn expanded_session_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let thread_id = + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("Investigate picker expansion"), + thread_id: Some(thread_id), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from("/tmp/codex")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + state.expanded_thread_id = Some(thread_id); + state.transcript_previews.insert( + thread_id, + TranscriptPreviewState::Loaded(vec![ + TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::User, + text: String::from("Show me the recent transcript"), + }, + TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::Assistant, + text: String::from("Here are the *last* few lines."), + }, + ]), + ); + + let width: u16 = 90; + let height: u16 = 11; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + let rendered = terminal + .backend() + .to_string() + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + + assert_snapshot!("resume_picker_expanded_session", rendered); + } + + #[test] + fn narrow_session_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("Investigate picker expansion"), + thread_id: Some( + ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id"), + ), + thread_name: None, + created_at: parse_timestamp_str("2026-04-28T16:30:00Z"), + updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), + cwd: Some(PathBuf::from("/tmp/codex")), + git_branch: Some(String::from("fcoury/session-picker")), + }; + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.all_rows = vec![row.clone()]; + state.filtered_rows = vec![row]; + state.relative_time_reference = + Some(parse_timestamp_str("2026-04-28T18:00:00Z").expect("timestamp")); + + let width: u16 = 58; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_narrow_session", + terminal.backend().to_string() + ); + } + + #[test] + fn session_list_more_indicators_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); + state.all_rows = (0..5) + .map(|idx| Row { + path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))), + preview: format!("item-{idx}"), + thread_id: None, + thread_name: None, + created_at: Some(now - Duration::hours(idx)), + updated_at: Some(now - Duration::minutes(idx * 5)), + cwd: None, + git_branch: None, + }) + .collect(); + state.filtered_rows = state.all_rows.clone(); + state.relative_time_reference = Some(now); + state.selected = 2; + state.scroll_top = 1; + state.update_viewport(/*rows*/ 6, /*width*/ 80); + + let width: u16 = 80; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert_snapshot!( + "resume_picker_more_indicators", + terminal.backend().to_string() + ); + } + + #[test] + fn density_toggle_clears_stale_more_indicator() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp"); + state.all_rows = (0..4) + .map(|idx| Row { + path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))), + preview: format!("item-{idx}"), + thread_id: None, + thread_name: None, + created_at: Some(now - Duration::hours(idx)), + updated_at: Some(now - Duration::minutes(idx * 5)), + cwd: None, + git_branch: None, + }) + .collect(); + state.filtered_rows = state.all_rows.clone(); + state.relative_time_reference = Some(now); + + let width: u16 = 80; + let height: u16 = 6; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + state.update_viewport(height as usize, width); + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + assert!(terminal.backend().to_string().contains("↓ more")); + + state.density = SessionListDensity::Dense; + state.update_viewport(height as usize, width); + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + render_list(&mut frame, area, &state); + } + terminal.flush().expect("flush"); + + assert!(!terminal.backend().to_string().contains("↓ more")); + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1828,7 +5245,7 @@ mod tests { fn ensure_minimum_rows_prefetches_when_underfilled() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -1859,54 +5276,87 @@ mod tests { } #[test] - fn column_visibility_hides_extra_date_column_when_narrow() { - let metrics = ColumnMetrics { - max_created_width: 8, - max_updated_width: 12, - max_branch_width: 0, - max_cwd_width: 0, - labels: Vec::new(), - }; + fn ensure_minimum_rows_does_not_prefetch_when_comfortable_cards_fill_view() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); - let created = column_visibility(/*area_width*/ 30, &metrics, ThreadSortKey::CreatedAt); - assert_eq!( - created, - ColumnVisibility { - show_created: true, - show_updated: false, - show_branch: false, - show_cwd: false, - } + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, ); + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), + make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), + make_row("/tmp/c.jsonl", "2025-01-03T00:00:00Z", "three"), + make_row("/tmp/d.jsonl", "2025-01-04T00:00:00Z", "four"), + ], + Some("2025-01-05T00:00:00Z"), + /*num_scanned_files*/ 4, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 6, /*width*/ 80); - let updated = column_visibility(/*area_width*/ 30, &metrics, ThreadSortKey::UpdatedAt); - assert_eq!( - updated, - ColumnVisibility { - show_created: false, - show_updated: true, - show_branch: false, - show_cwd: false, - } - ); + state.ensure_minimum_rows_for_view(/*minimum_rows*/ 6); - let wide = column_visibility(/*area_width*/ 40, &metrics, ThreadSortKey::CreatedAt); - assert_eq!( - wide, - ColumnVisibility { - show_created: true, - show_updated: true, - show_branch: false, - show_cwd: false, - } + assert!(recorded_requests.lock().unwrap().is_empty()); + } + + #[test] + fn ensure_minimum_rows_still_prefetches_when_dense_rows_underfill_view() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, ); + state.density = SessionListDensity::Dense; + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), + make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), + ], + Some("2025-01-03T00:00:00Z"), + /*num_scanned_files*/ 2, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 10, /*width*/ 80); + + state.ensure_minimum_rows_for_view(/*minimum_rows*/ 10); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert!(guard[0].search_token.is_none()); + } + + #[test] + fn list_viewport_width_matches_rendered_list_inset() { + assert_eq!(list_viewport_width(/*width*/ 80), 76); + assert_eq!(list_viewport_width(/*width*/ 3), 0); } #[tokio::test] async fn toggle_sort_key_reloads_with_new_sort() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -1930,15 +5380,123 @@ mod tests { .handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) .await .unwrap(); + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); let guard = recorded_requests.lock().unwrap(); assert_eq!(guard.len(), 2); assert_eq!(guard[1].sort_key, ThreadSortKey::CreatedAt); } + #[tokio::test] + async fn default_filter_focus_arrows_reload_with_new_filter() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ false, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + state.start_initial_load(); + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, Some(PathBuf::from("/tmp/project"))); + } + + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 2); + assert_eq!(guard[1].cwd_filter, None); + } + + #[tokio::test] + async fn all_filter_can_switch_back_to_cwd_when_cwd_candidate_exists() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + Some(PathBuf::from("/tmp/project")), + SessionPickerAction::Resume, + ); + + state.start_initial_load(); + { + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, None); + } + + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 2); + assert_eq!(guard[1].cwd_filter, Some(PathBuf::from("/tmp/project"))); + } + + #[tokio::test] + async fn filter_stays_all_when_no_cwd_candidate_exists() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::Any, + /*show_all*/ false, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + assert_eq!( + search_line(&state, /*width*/ 80) + .to_string() + .matches("Cwd") + .count(), + 0 + ); + + state.start_initial_load(); + state + .handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)) + .await + .unwrap(); + + let guard = recorded_requests.lock().unwrap(); + assert_eq!(guard.len(), 1); + assert_eq!(guard[0].cwd_filter, None); + } + #[tokio::test] async fn page_navigation_uses_view_rows() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -1961,7 +5519,7 @@ mod tests { items, /*next_cursor*/ None, /*num_scanned_files*/ 20, /*reached_scan_cap*/ false, )); - state.update_view_rows(/*rows*/ 5); + state.update_viewport(/*rows*/ 5, /*width*/ 80); assert_eq!(state.selected, 0); state @@ -1981,11 +5539,71 @@ mod tests { .await .unwrap(); assert_eq!(state.selected, 5); + + state + .handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.selected, 19); + + state + .handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.selected, 0); + } + + #[tokio::test] + async fn end_jumps_to_last_known_row_and_starts_loading_more() { + let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let request_sink = recorded_requests.clone(); + let loader = page_only_loader(move |req: PageLoadRequest| { + request_sink.lock().unwrap().push(req); + }); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let items = (0..10) + .map(|idx| { + make_row( + &format!("/tmp/{idx}.jsonl"), + "2026-05-02T12:00:00Z", + &format!("row {idx}"), + ) + }) + .collect(); + state.reset_pagination(); + state.ingest_page(page( + items, + Some("cursor-1"), + /*num_scanned_files*/ 10, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + state + .handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 9); + assert!(state.pagination.loading.is_pending()); + assert_eq!(recorded_requests.lock().unwrap().len(), 1); + assert_eq!( + picker_footer_progress_label(&state, /*list_height*/ 5, /*width*/ 80), + " 10 / 10… · 100% " + ); } #[tokio::test] async fn enter_on_row_without_resolvable_thread_id_shows_inline_error() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2024,7 +5642,7 @@ mod tests { #[tokio::test] async fn enter_on_pathless_thread_uses_thread_id() { - let loader: PageLoader = Arc::new(|_| {}); + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2091,9 +5709,220 @@ mod tests { assert_eq!(row.thread_name, Some(String::from("Named thread"))); } + #[test] + fn thread_to_transcript_cells_renders_core_message_types() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items: vec![ + ThreadItem::UserMessage { + id: String::from("user-1"), + content: vec![codex_app_server_protocol::UserInput::Text { + text: String::from("hello from user"), + text_elements: Vec::new(), + }], + }, + ThreadItem::AgentMessage { + id: String::from("agent-1"), + text: String::from("hello from assistant"), + phase: None, + memory_citation: None, + }, + ThreadItem::Plan { + id: String::from("plan-1"), + text: String::from("1. Do the thing"), + }, + ], + items_view: codex_app_server_protocol::TurnItemsView::Full, + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + }; + + let rendered = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("hello from user")); + assert!(rendered.contains("hello from assistant")); + assert!(rendered.contains("Proposed Plan")); + assert!(rendered.contains("Do the thing")); + } + + #[test] + fn thread_to_transcript_cells_hides_raw_reasoning_when_not_enabled() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items: vec![ThreadItem::Reasoning { + id: String::from("reasoning-1"), + summary: Vec::new(), + content: vec![String::from("private raw chain of thought")], + }], + items_view: codex_app_server_protocol::TurnItemsView::Full, + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + }; + + let hidden = thread_to_transcript_cells(&thread, RawReasoningVisibility::Hidden) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + let visible = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(!hidden.contains("private raw chain of thought")); + assert!(visible.contains("private raw chain of thought")); + } + + #[test] + fn thread_to_transcript_cells_shows_raw_reasoning_over_summary_when_enabled() { + use transcript::thread_to_transcript_cells; + + let thread_id = ThreadId::new(); + let thread = Thread { + id: thread_id.to_string(), + forked_from_id: None, + preview: String::from("preview"), + ephemeral: false, + model_provider: String::from("openai"), + created_at: 1, + updated_at: 2, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: String::from("0.0.0"), + source: codex_app_server_protocol::SessionSource::Cli, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![codex_app_server_protocol::Turn { + id: String::from("turn-1"), + items: vec![ThreadItem::Reasoning { + id: String::from("reasoning-1"), + summary: vec![String::from("public summary")], + content: vec![String::from("raw reasoning content")], + }], + items_view: codex_app_server_protocol::TurnItemsView::Full, + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + }; + + let rendered = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("raw reasoning content")); + assert!(!rendered.contains("public summary")); + } + #[tokio::test] - async fn up_at_bottom_does_not_scroll_when_visible() { - let loader: PageLoader = Arc::new(|_| {}); + async fn moving_to_last_card_scrolls_when_cards_exceed_viewport() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..3 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 3, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + assert_eq!(state.scroll_top, 1); + + state + .handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 2); + assert_eq!(state.scroll_top, 2); + } + + #[tokio::test] + async fn up_from_bottom_keeps_viewport_stable_when_card_remains_visible() { + let loader = page_only_loader(|_| {}); let mut state = PickerState::new( FrameRequester::test_dummy(), loader, @@ -2116,28 +5945,102 @@ mod tests { items, /*next_cursor*/ None, /*num_scanned_files*/ 10, /*reached_scan_cap*/ false, )); - state.update_view_rows(/*rows*/ 5); + state.update_viewport(/*rows*/ 5, /*width*/ 80); state.selected = state.filtered_rows.len().saturating_sub(1); state.ensure_selected_visible(); let initial_top = state.scroll_top; - assert_eq!(initial_top, state.filtered_rows.len().saturating_sub(5)); + assert_eq!(initial_top, state.filtered_rows.len().saturating_sub(1)); state .handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) .await .unwrap(); - assert_eq!(state.scroll_top, initial_top); + assert_eq!(state.scroll_top, initial_top.saturating_sub(1)); assert_eq!(state.selected, state.filtered_rows.len().saturating_sub(2)); } + #[tokio::test] + async fn up_scrolls_only_after_crossing_top_edge() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..10 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 10, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + state.selected = 8; + state.scroll_top = 8; + + state + .handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)) + .await + .unwrap(); + + assert_eq!(state.selected, 7); + assert_eq!(state.scroll_top, 7); + } + + #[test] + fn list_reports_more_rows_above_and_below() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let mut items = Vec::new(); + for idx in 0..5 { + let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); + let preview = format!("item-{idx}"); + let path = format!("/tmp/item-{idx}.jsonl"); + items.push(make_row(&path, &ts, &preview)); + } + + state.reset_pagination(); + state.ingest_page(page( + items, /*next_cursor*/ None, /*num_scanned_files*/ 5, + /*reached_scan_cap*/ false, + )); + state.update_viewport(/*rows*/ 5, /*width*/ 80); + + assert!(!state.has_more_above()); + assert!(state.has_more_below(/*viewport_height*/ 5)); + + state.scroll_top = 2; + + assert!(state.has_more_above()); + assert!(state.has_more_below(/*viewport_height*/ 5)); + } + #[tokio::test] async fn set_query_loads_until_match_and_respects_scan_cap() { let recorded_requests: Arc>> = Arc::new(Mutex::new(Vec::new())); let request_sink = recorded_requests.clone(); - let loader: PageLoader = Arc::new(move |req: PageLoadRequest| { + let loader = page_only_loader(move |req: PageLoadRequest| { request_sink.lock().unwrap().push(req); }); @@ -2170,7 +6073,7 @@ mod tests { }; state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: first_request.request_token, search_token: first_request.search_token, page: Ok(page( @@ -2192,7 +6095,7 @@ mod tests { assert!(state.filtered_rows.is_empty()); state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page( @@ -2221,7 +6124,7 @@ mod tests { }; state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page( @@ -2236,7 +6139,7 @@ mod tests { assert_eq!(recorded_requests.lock().unwrap().len(), 1); state - .handle_background_event(BackgroundEvent::PageLoaded { + .handle_background_event(BackgroundEvent::Page { request_token: active_request.request_token, search_token: active_request.search_token, page: Ok(page( @@ -2253,4 +6156,61 @@ mod tests { assert!(!state.search_state.is_active()); assert!(state.pagination.reached_scan_cap); } + + #[tokio::test] + async fn esc_with_empty_query_starts_fresh() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await + .expect("handle key"); + + assert!(matches!(selection, Some(SessionSelection::StartFresh))); + } + + #[tokio::test] + async fn esc_with_query_clears_search_and_preserves_selected_result() { + let loader = page_only_loader(|_| {}); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + loader, + ProviderFilter::MatchDefault(String::from("openai")), + /*show_all*/ true, + /*filter_cwd*/ None, + SessionPickerAction::Resume, + ); + state.reset_pagination(); + state.ingest_page(page( + vec![ + make_row("/tmp/alpha.jsonl", "2025-01-03T00:00:00Z", "alpha"), + make_row("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta"), + ], + /*next_cursor*/ None, + /*num_scanned_files*/ 2, + /*reached_scan_cap*/ false, + )); + state.set_query(String::from("beta")); + + let selection = state + .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .await + .expect("handle key"); + + assert!(selection.is_none()); + assert!(state.query.is_empty()); + assert_eq!(state.filtered_rows.len(), 2); + assert_eq!( + state.filtered_rows[state.selected].path.as_deref(), + Some(Path::new("/tmp/beta.jsonl")) + ); + } } diff --git a/codex-rs/tui/src/resume_picker/transcript.rs b/codex-rs/tui/src/resume_picker/transcript.rs new file mode 100644 index 0000000000..4fe75efe63 --- /dev/null +++ b/codex-rs/tui/src/resume_picker/transcript.rs @@ -0,0 +1,214 @@ +use std::sync::Arc; + +use crate::app_server_session::AppServerSession; +use crate::history_cell::AgentMarkdownCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::ReasoningSummaryCell; +use crate::history_cell::UserHistoryCell; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadItem; +use codex_protocol::ThreadId; +use codex_protocol::items::UserMessageItem; +use ratatui::style::Stylize as _; +use ratatui::text::Line; + +pub(crate) type TranscriptCells = Vec>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RawReasoningVisibility { + Hidden, + Visible, +} + +pub(crate) async fn load_session_transcript( + app_server: &mut AppServerSession, + thread_id: ThreadId, + raw_reasoning_visibility: RawReasoningVisibility, +) -> std::io::Result { + let thread = app_server + .thread_read(thread_id, /*include_turns*/ true) + .await + .map_err(std::io::Error::other)?; + Ok(thread_to_transcript_cells( + &thread, + raw_reasoning_visibility, + )) +} + +pub(crate) fn thread_to_transcript_cells( + thread: &Thread, + raw_reasoning_visibility: RawReasoningVisibility, +) -> TranscriptCells { + let cwd = thread.cwd.as_path(); + let mut cells: TranscriptCells = Vec::new(); + for item in thread.turns.iter().flat_map(|turn| turn.items.iter()) { + match item { + ThreadItem::UserMessage { id, content } => { + let item = UserMessageItem { + id: id.clone(), + content: content + .iter() + .cloned() + .map(codex_app_server_protocol::UserInput::into_core) + .collect(), + }; + cells.push(Arc::new(UserHistoryCell { + message: item.message(), + text_elements: item.text_elements(), + local_image_paths: item.local_image_paths(), + remote_image_urls: item.image_urls(), + })); + } + ThreadItem::AgentMessage { text, .. } => { + if !text.trim().is_empty() { + cells.push(Arc::new(AgentMarkdownCell::new(text.clone(), cwd))); + } + } + ThreadItem::Plan { text, .. } => { + if !text.trim().is_empty() { + cells.push(Arc::new(crate::history_cell::new_proposed_plan( + text.clone(), + cwd, + ))); + } + } + ThreadItem::Reasoning { + summary, content, .. + } => { + let text = if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible) + && !content.is_empty() + { + content.join("\n\n") + } else { + summary.join("\n\n") + }; + if !text.trim().is_empty() { + cells.push(Arc::new(ReasoningSummaryCell::new( + "Reasoning".to_string(), + text, + cwd, + /*transcript_only*/ false, + ))); + } + } + other => { + if let Some(cell) = fallback_transcript_cell(other) { + cells.push(Arc::new(cell)); + } + } + } + } + if cells.is_empty() { + cells.push(Arc::new(PlainHistoryCell::new(vec![ + "No transcript content available".italic().dim().into(), + ]))); + } + cells +} + +fn fallback_transcript_cell(item: &ThreadItem) -> Option { + let lines = match item { + ThreadItem::HookPrompt { fragments, .. } => fragments + .iter() + .map(|fragment| { + vec![ + "hook prompt: ".dim(), + fragment.text.trim().to_string().into(), + ] + .into() + }) + .collect::>(), + ThreadItem::CommandExecution { + command, + status, + aggregated_output, + exit_code, + .. + } => { + let mut lines: Vec> = + vec![vec!["$ ".dim(), command.clone().into()].into()]; + lines.push( + format!( + "status: {status:?}{}", + exit_code + .map(|code| format!(" · exit {code}")) + .unwrap_or_default() + ) + .dim() + .into(), + ); + if let Some(output) = aggregated_output.as_deref() + && !output.trim().is_empty() + { + lines.extend( + output + .lines() + .map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()), + ); + } + lines + } + ThreadItem::FileChange { + changes, status, .. + } => vec![ + format!("file changes: {status:?} · {} changes", changes.len()) + .dim() + .into(), + ], + ThreadItem::McpToolCall { + server, + tool, + status, + .. + } => vec![ + format!("mcp tool: {server}/{tool} · {status:?}") + .dim() + .into(), + ], + ThreadItem::DynamicToolCall { + namespace, + tool, + status, + .. + } => { + let name = namespace + .as_ref() + .map(|namespace| format!("{namespace}/{tool}")) + .unwrap_or_else(|| tool.clone()); + vec![format!("tool: {name} · {status:?}").dim().into()] + } + ThreadItem::CollabAgentToolCall { tool, status, .. } => { + vec![format!("agent tool: {tool:?} · {status:?}").dim().into()] + } + ThreadItem::WebSearch { query, .. } => { + vec![vec!["web search: ".dim(), query.clone().into()].into()] + } + ThreadItem::ImageView { path, .. } => { + vec![format!("image: {}", path.as_path().display()).dim().into()] + } + ThreadItem::ImageGeneration { + status, saved_path, .. + } => { + let saved = saved_path + .as_ref() + .map(|path| format!(" · {}", path.as_path().display())) + .unwrap_or_default(); + vec![format!("image generation: {status}{saved}").dim().into()] + } + ThreadItem::EnteredReviewMode { review, .. } => { + vec![vec!["review started: ".dim(), review.clone().into()].into()] + } + ThreadItem::ExitedReviewMode { review, .. } => { + vec![vec!["review finished: ".dim(), review.clone().into()].into()] + } + ThreadItem::ContextCompaction { .. } => { + vec!["context compacted".dim().into()] + } + ThreadItem::UserMessage { .. } + | ThreadItem::AgentMessage { .. } + | ThreadItem::Plan { .. } + | ThreadItem::Reasoning { .. } => return None, + }; + (!lines.is_empty()).then(|| PlainHistoryCell::new(lines)) +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap new file mode 100644 index 0000000000..62cb8d199b --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 120,)" +--- +❯ 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap new file mode 100644 index 0000000000..94f74b55f3 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_auto_hidden_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 100,)" +--- +❯ 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap new file mode 100644 index 0000000000..9b8c39ef50 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_all_forced_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 48,)" +--- +❯ 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap new file mode 100644 index 0000000000..64120ef26a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_cwd.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(false,\nSome(PathBuf::from(\"/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs\")),\n100,)" +--- +❯ 15m ago Propose session picker redesign with enough title text to exercise truncation diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap new file mode 100644 index 0000000000..9b8c39ef50 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_narrow.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_dense_row_snapshot(true, None, 48,)" +--- +❯ 15m ago Propose session picker redesig... diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap new file mode 100644 index 0000000000..50e37a46b9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_dense_no_blank_lines.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- + 15m ago First dense row +❯ 15m ago Second dense row diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap new file mode 100644 index 0000000000..23470e5a6f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_expanded_session.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/resume_picker.rs +expression: rendered +--- +⌄ Investigate picker expansion + │ Session: 019dabc1-0ef5-7431-b81c-03037f51f62c + │ Created: 1 hour ago · 2026-04-28 16:30:00 + │ Updated: 15 minutes ago · 2026-04-28 17:45:00 + │ Directory: /tmp/codex + │ Branch:  fcoury/session-picker + │ + │ Conversation: + │ Show me the recent transcript + └ Here are the last few lines. diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap new file mode 100644 index 0000000000..65fb6f7857 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_compact.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: "footer_snapshot(&state, 96, 20)" +--- +───────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─ + enter resume esc clear ctrl+c quit tab focus ←/→ option + ctrl+o comfy ctrl+t preview ctrl+e exp ↑/↓ browse diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap new file mode 100644 index 0000000000..13c111dc1d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_footer_wide.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: "footer_snapshot(&state, 220, 20)" +--- +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 0 / 0 · 100% ─ + enter resume esc start new ctrl+c quit tab focus sort/filter ←/→ change option + ctrl+o dense view ctrl+t transcript ctrl+e expand ↑/↓ browse diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap new file mode 100644 index 0000000000..76a645aa07 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_more_indicators.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +↑ more +❯ item-2 + 10m ago ⌁ no cwd  no branch + + item-3 +↓ more diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap new file mode 100644 index 0000000000..db583b9957 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_narrow_session.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +❯ Investigate picker expansion + 15m ago ⌁ /tmp/codex +  fcoury/session-picker diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap new file mode 100644 index 0000000000..f48b6543cd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_search_line_sort_filter_tabs.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/resume_picker.rs +expression: terminal.backend().to_string() +--- +Type to search Filter: [Cwd] All Sort: [Updated] Created diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap index 8948163563..b882050d58 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_table.snap @@ -2,7 +2,11 @@ source: tui/src/resume_picker.rs expression: snapshot --- - Created Updated Branch CWD Conversation - 16 minutes ago 42 seconds ago - - Fix resume picker timestamps -> 1 hour ago 35 minutes ago - - Investigate lazy pagination cap - 2 hours ago 2 hours ago - - Explain the codebase + Fix resume picker timestamps + 42s ago ⌁ no cwd  no branch + +❯ Investigate lazy pagination cap + 35m ago ⌁ no cwd  no branch + + Explain the codebase + 2h ago ⌁ no cwd  no branch diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap new file mode 100644 index 0000000000..fa6b47cbbd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_transcript_loading_overlay.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/resume_picker.rs +expression: snapshot +--- +❯ Find pending threads and emails + - ⌁ no cwd  no branch + + Plan raw scrollback mod Loading transcript… + - ⌁ no cwd branch diff --git a/codex-rs/tui/src/workspace_command.rs b/codex-rs/tui/src/workspace_command.rs index f0267699a2..c6b2e770e6 100644 --- a/codex-rs/tui/src/workspace_command.rs +++ b/codex-rs/tui/src/workspace_command.rs @@ -1,14 +1,14 @@ //! App-server-backed workspace command execution for TUI-owned background lookups. //! -//! This module is the TUI boundary for short, non-interactive commands that need to run wherever +//! This module is the TUI boundary for non-interactive commands that need to run wherever //! the active workspace lives. Callers describe a command in terms of argv, cwd, environment //! overrides, timeout, and output cap; the runner translates that request to app-server //! `command/exec`. Keeping this as a TUI-local abstraction lets status surfaces avoid knowing //! whether the current app-server is embedded or remote. //! -//! Commands sent through this path are best-effort metadata probes. They should not prompt for -//! stdin, should tolerate failure by omitting optional UI, and should keep output bounded so a -//! status-line refresh cannot grow into an unbounded background process. +//! Commands sent through this path should not prompt for stdin. Most callers should keep output +//! bounded so metadata refreshes cannot grow into unbounded background processes; callers that own a +//! full user-visible payload, such as `/diff`, can explicitly opt out of output capping. use std::collections::HashMap; use std::future::Future; @@ -45,17 +45,20 @@ pub(crate) struct WorkspaceCommand { pub(crate) timeout: Duration, /// Maximum captured stdout/stderr bytes returned by app-server. pub(crate) output_bytes_cap: usize, + /// Whether app-server should return uncapped stdout/stderr. + pub(crate) disable_output_cap: bool, } impl WorkspaceCommand { - /// Creates a workspace command with conservative defaults for status-style metadata probes. + /// Creates a workspace command with conservative defaults for metadata probes. pub(crate) fn new(argv: impl IntoIterator>) -> Self { Self { argv: argv.into_iter().map(Into::into).collect(), cwd: None, env: HashMap::new(), - timeout: Duration::from_secs(5), + timeout: Duration::from_secs(/*secs*/ 5), output_bytes_cap: 64 * 1024, + disable_output_cap: false, } } @@ -70,6 +73,18 @@ impl WorkspaceCommand { self.env.insert(key.into(), Some(value.into())); self } + + /// Sets the maximum wall-clock duration before app-server cancels the command. + pub(crate) fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Requests uncapped stdout/stderr capture from app-server. + pub(crate) fn disable_output_cap(mut self) -> Self { + self.disable_output_cap = true; + self + } } /// Captured result from a completed workspace command. @@ -176,8 +191,9 @@ impl WorkspaceCommandExecutor for AppServerWorkspaceCommandRunner { tty: false, stream_stdin: false, stream_stdout_stderr: false, - output_bytes_cap: Some(command.output_bytes_cap), - disable_output_cap: false, + output_bytes_cap: (!command.disable_output_cap) + .then_some(command.output_bytes_cap), + disable_output_cap: command.disable_output_cap, disable_timeout: false, timeout_ms: Some(timeout_ms), cwd: command.cwd, diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 8c225e4d3b..2fc585d7e9 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -596,6 +596,10 @@ install_release() { cp "$vendor_root/path/rg" "$stage_release/codex-resources/rg" chmod 0755 "$stage_release/codex" chmod 0755 "$stage_release/codex-resources/rg" + if [ -f "$vendor_root/codex-resources/bwrap" ]; then + cp "$vendor_root/codex-resources/bwrap" "$stage_release/codex-resources/bwrap" + chmod 0755 "$stage_release/codex-resources/bwrap" + fi if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then rm -rf "$release_dir" @@ -611,7 +615,11 @@ release_dir_is_complete() { [ -d "$release_dir" ] && [ -x "$release_dir/codex" ] && [ -x "$release_dir/codex-resources/rg" ] && - [ "$(basename "$release_dir")" = "$expected_version-$expected_target" ] + [ "$(basename "$release_dir")" = "$expected_version-$expected_target" ] && + case "$expected_target" in + *linux*) [ -x "$release_dir/codex-resources/bwrap" ] ;; + *) true ;; + esac } update_current_link() { diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 5bbee755e9..2a0cd0028d 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -58,6 +58,16 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Retain temporary staging directories instead of deleting them.", ) + parser.add_argument( + "--allow-missing-native-component", + dest="allow_missing_native_components", + action="append", + default=[], + help=( + "Native component that may be absent from reused workflow artifacts. " + "Intended for CI compatibility only; release staging should not use this." + ), + ) return parser.parse_args() @@ -147,6 +157,8 @@ def main() -> int: packages = expand_packages(list(args.packages)) native_components = collect_native_components(packages) + allow_missing_native_components = set(args.allow_missing_native_components) + native_components_to_install = native_components - allow_missing_native_components vendor_temp_root: Path | None = None vendor_src: Path | None = None @@ -155,12 +167,12 @@ def main() -> int: final_messages = [] try: - if native_components: + if native_components_to_install: workflow_url, resolved_head_sha = resolve_workflow_url( args.release_version, args.workflow_url ) vendor_temp_root = Path(tempfile.mkdtemp(prefix="npm-native-", dir=runner_temp)) - install_native_components(workflow_url, native_components, vendor_temp_root) + install_native_components(workflow_url, native_components_to_install, vendor_temp_root) vendor_src = vendor_temp_root / "vendor" if resolved_head_sha: @@ -185,6 +197,9 @@ def main() -> int: if vendor_src is not None: cmd.extend(["--vendor-src", str(vendor_src)]) + for component in sorted(allow_missing_native_components): + cmd.extend(["--allow-missing-native-component", component]) + try: run_command(cmd) finally: