diff --git a/codex-rs/config/examples/generate-proto.rs b/codex-rs/config/examples/generate-proto.rs new file mode 100644 index 0000000000000000000000000000000000000000..03f0f796da44c26f2bf91f5eab375b20ec420fcc --- /dev/null +++ b/codex-rs/config/examples/generate-proto.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let Some(proto_dir_arg) = std::env::args().nth(1) else { + eprintln!("Usage: generate-proto "); + std::process::exit(1); + }; + + let proto_dir = PathBuf::from(proto_dir_arg); + let proto_file = proto_dir.join("codex.thread_config.v1.proto"); + + tonic_prost_build::configure() + .build_client(true) + .build_server(true) + .out_dir(&proto_dir) + .compile_protos(&[proto_file], &[proto_dir])?; + + Ok(()) +} diff --git a/codex-rs/config/scripts/generate-proto.sh b/codex-rs/config/scripts/generate-proto.sh new file mode 100644 index 0000000000000000000000000000000000000000..86af22b8957c5d1bae44eaf8620e18d251db242b --- /dev/null +++ b/codex-rs/config/scripts/generate-proto.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../../.." && pwd)" +proto_dir="$repo_root/codex-rs/config/src/thread_config/proto" +generated="$proto_dir/codex.thread_config.v1.rs" +tmpdir="$(mktemp -d)" + +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +( + cd "$repo_root/codex-rs" + CARGO_TARGET_DIR="$tmpdir/target" cargo run \ + -p codex-config \ + --example generate-proto \ + -- "$proto_dir" +) + +if ! sed -n '2p' "$generated" | grep -q 'clippy::trivially_copy_pass_by_ref'; then + { + sed -n '1p' "$generated" + printf '#![allow(clippy::trivially_copy_pass_by_ref)]\n' + sed '1d' "$generated" + } > "$tmpdir/generated.rs" + mv "$tmpdir/generated.rs" "$generated" +fi + +rustfmt --edition 2024 "$generated" + +awk ' + NR == 3 && previous ~ /clippy::trivially_copy_pass_by_ref/ && $0 != "" { print "" } + { print; previous = $0 } +' "$generated" > "$tmpdir/formatted.rs" +mv "$tmpdir/formatted.rs" "$generated" diff --git a/codex-rs/config/src/application_requirements_tests.rs b/codex-rs/config/src/application_requirements_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e16f0fa69afa8e91c9ac5ee194ae34c332d9b525 --- /dev/null +++ b/codex-rs/config/src/application_requirements_tests.rs @@ -0,0 +1,251 @@ +//! Application requirements use managed precedence and deny unlisted destinations. + +use super::*; +use crate::ConfigRequirements; +use crate::ConfigRequirementsToml; +use crate::ConfigRequirementsWithSources; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::Sourced; +use crate::compose_requirements_for_hostname; +use pretty_assertions::assert_eq; + +const INSTALLATION: &str = r#" +[application.network.domains] +"shared.example.com" = "allow" +"installed.example.com" = "allow" +"blocked.example.com" = "deny" +"#; +const WORKSPACE: &str = r#" +[application.network.domains] +"shared.example.com" = "allow" +"workspace.example.com" = "allow" +"blocked.example.com" = "allow" +"#; +const MERGED: &str = r#" +[application.network.domains] +"shared.example.com" = "allow" +"installed.example.com" = "allow" +"workspace.example.com" = "allow" +"blocked.example.com" = "allow" +"#; + +fn parse(contents: &str) -> ConfigRequirementsToml { + toml::from_str(contents).expect("valid application requirements") +} + +fn cloud_source() -> RequirementSource { + RequirementSource::EnterpriseManaged { + id: "workspace".to_string(), + name: "Workspace policy".to_string(), + } +} + +fn managed_source() -> RequirementSource { + RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements".to_string(), + } +} + +#[test] +fn application_network_defaults_to_enabled_and_denies_unlisted_domains() { + assert!(parse("[application]").is_empty()); + let requirements = parse("[application.network]"); + assert!(!requirements.is_empty()); + assert_eq!( + requirements.application, + Some(ApplicationRequirementsToml { + network: Some(ApplicationNetworkRequirementsToml { + enabled: true, + domains: BTreeMap::new(), + }), + }), + ); + assert_eq!( + parse("[application.network.domains]\n\"EXAMPLE.com.\" = \"deny\""), + parse("[application.network.domains]\n\"example.com\" = \"deny\""), + ); +} + +#[test] +fn application_network_is_preserved_from_each_managed_source() { + for source in [ + RequirementSource::SystemRequirementsToml { + file: crate::AbsolutePathBuf::try_from(std::env::temp_dir().join("requirements.toml")) + .expect("absolute requirements path"), + }, + managed_source(), + cloud_source(), + ] { + let requirements = compose_requirements_for_hostname( + [RequirementsLayerEntry::from_toml( + source.clone(), + INSTALLATION, + )], + /*hostname*/ None, + ) + .expect("compose application requirements") + .expect("application requirements are not empty"); + assert_eq!( + requirements, + ConfigRequirementsWithSources { + application: Some(Sourced::new( + parse(INSTALLATION).application.expect("application"), + source, + )), + ..Default::default() + }, + ); + let normalized: ConfigRequirements = + requirements.clone().try_into().expect("runtime policy"); + assert_eq!(normalized.application, requirements.application); + } +} + +#[test] +fn application_network_uses_regular_toml_precedence() { + let installed_wins = MERGED.replace( + "\"blocked.example.com\" = \"allow\"", + "\"blocked.example.com\" = \"deny\"", + ); + let disabled_installation = format!("[application.network]\nenabled = false\n{INSTALLATION}"); + for (low, high, expected) in [ + (INSTALLATION, WORKSPACE, MERGED), + (WORKSPACE, INSTALLATION, installed_wins.as_str()), + ( + INSTALLATION, + "[application.network]\nenabled = false", + disabled_installation.as_str(), + ), + ( + disabled_installation.as_str(), + "[application.network]\nenabled = true", + INSTALLATION, + ), + ( + "[application.network]\nenabled = false", + INSTALLATION, + disabled_installation.as_str(), + ), + (INSTALLATION, "[application.network]", INSTALLATION), + (INSTALLATION, "", INSTALLATION), + ("[application]", INSTALLATION, INSTALLATION), + ("[application.network]", WORKSPACE, WORKSPACE), + ( + "[application.network.domains]\n'example.com' = 'allow'", + "[application.network.domains]\n'EXAMPLE.COM.' = 'deny'", + "[application.network.domains]\n'example.com' = 'deny'", + ), + ( + "[application.network.domains]\n'EXAMPLE.COM.' = 'deny'", + "[application.network.domains]\n'example.com' = 'allow'", + "[application.network.domains]\n'example.com' = 'allow'", + ), + ] { + let composed = compose_requirements_for_hostname( + [ + RequirementsLayerEntry::from_toml(managed_source(), low), + RequirementsLayerEntry::from_toml(cloud_source(), high), + ], + /*hostname*/ None, + ) + .expect("compose application requirements") + .expect("configured requirements"); + assert_eq!( + composed.into_toml(), + parse(expected), + "low: {low}, high: {high}" + ); + } +} + +#[test] +fn legacy_application_merge_uses_whole_table_precedence() { + for (high, low) in [ + (INSTALLATION, WORKSPACE), + (WORKSPACE, INSTALLATION), + ("[application.network]\nenabled = false", INSTALLATION), + ("[application]", INSTALLATION), + ] { + let mut composed = ConfigRequirementsWithSources::default(); + composed.merge_unset_fields(managed_source(), parse(high)); + composed.merge_unset_fields(cloud_source(), parse(low)); + assert_eq!( + composed, + ConfigRequirementsWithSources { + application: Some(Sourced::new( + parse(high).application.expect("application"), + managed_source(), + )), + ..Default::default() + }, + ); + } + let mut composed = ConfigRequirementsWithSources::default(); + composed.merge_unset_fields(managed_source(), parse("")); + composed.merge_unset_fields(cloud_source(), parse(INSTALLATION)); + assert_eq!(composed.into_toml(), parse(INSTALLATION)); +} + +#[test] +fn invalid_application_policy_is_rejected_before_layer_overrides() { + for invalid in [ + "[application.network]\nenabled = 'true'", + "[application.network]\nallowed_domains = ['example.com']", + "[application.network.domains]\n'example.com' = 'prompt'", + "[application.network.domains]\n'*.example.com' = 'allow'", + "[application.network.domains]\n'https://example.com' = 'allow'", + "[application.network.domains]\n'example.com:443' = 'allow'", + "[application.network.domains]\n'example..com' = 'allow'", + "[application.network.domains]\n' example.com' = 'allow'", + "[application.network.domains]\n'EXAMPLE.com' = 'allow'\n'example.com.' = 'deny'", + ] { + assert!( + toml::from_str::(invalid).is_err(), + "{invalid}" + ); + assert!( + compose_requirements_for_hostname( + [ + RequirementsLayerEntry::from_toml(cloud_source(), invalid), + RequirementsLayerEntry::from_toml(managed_source(), INSTALLATION), + ], + /*hostname*/ None, + ) + .is_err(), + "{invalid}" + ); + } +} + +#[test] +fn cloud_bundle_application_network_uses_managed_precedence() { + let base_dir = + crate::AbsolutePathBuf::try_from(std::env::temp_dir()).expect("absolute base directory"); + let bundle = crate::CloudConfigBundle { + requirements_toml: crate::CloudRequirementsTomlBundle { + enterprise_managed: vec![crate::CloudRequirementsFragment { + id: "workspace".to_string(), + name: "Workspace".to_string(), + contents: WORKSPACE.to_string(), + }], + }, + ..Default::default() + }; + let mut layers = crate::CloudConfigBundleLayers::from_bundle(bundle, &base_dir) + .expect("load cloud bundle") + .enterprise_managed_requirements; + layers.push(RequirementsLayerEntry::from_toml( + managed_source(), + INSTALLATION, + )); + let composed = compose_requirements_for_hostname(layers, /*hostname*/ None) + .expect("compose cloud and installation requirements") + .expect("requirements"); + let expected = MERGED.replace( + "\"blocked.example.com\" = \"allow\"", + "\"blocked.example.com\" = \"deny\"", + ); + assert_eq!(composed.into_toml(), parse(&expected)); +} diff --git a/codex-rs/config/src/auth_policy.rs b/codex-rs/config/src/auth_policy.rs new file mode 100644 index 0000000000000000000000000000000000000000..a38765c3edd778443aee634cba3587a490523714 --- /dev/null +++ b/codex-rs/config/src/auth_policy.rs @@ -0,0 +1,61 @@ +use codex_protocol::config_types::ForcedLoginMethod; + +/// Authentication restrictions supplied by locally managed requirements. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ManagedAuthPolicy { + pub allowed_login_methods: Option>, + pub allowed_chatgpt_workspaces: Option>, +} + +impl ManagedAuthPolicy { + pub fn allows_login_method( + &self, + method: ForcedLoginMethod, + forced_login_method: Option, + forced_workspaces: Option<&[String]>, + ) -> bool { + forced_login_method.is_none_or(|forced| forced == method) + && self + .allowed_login_methods + .as_ref() + .is_none_or(|allowed| allowed.contains(&method)) + && (method != ForcedLoginMethod::Chatgpt + || self + .effective_chatgpt_workspaces(forced_workspaces) + .is_none_or(|workspaces| !workspaces.is_empty())) + } + + pub fn allowed_login_methods( + &self, + forced_login_method: Option, + forced_workspaces: Option<&[String]>, + ) -> Vec { + [ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt] + .into_iter() + .filter(|method| { + self.allows_login_method(*method, forced_login_method, forced_workspaces) + }) + .collect() + } + + pub fn effective_chatgpt_workspaces( + &self, + forced_workspaces: Option<&[String]>, + ) -> Option> { + match ( + forced_workspaces, + self.allowed_chatgpt_workspaces.as_deref(), + ) { + (Some(forced), Some(allowed)) => Some( + forced + .iter() + .filter(|workspace| allowed.contains(workspace)) + .cloned() + .collect(), + ), + (Some(forced), None) => Some(forced.to_vec()), + (None, Some(allowed)) => Some(allowed.to_vec()), + (None, None) => None, + } + } +} diff --git a/codex-rs/config/src/browser_computer_use_requirements.rs b/codex-rs/config/src/browser_computer_use_requirements.rs new file mode 100644 index 0000000000000000000000000000000000000000..f0dba54095de6116d27f9e2d4e5adcfcb301a66b --- /dev/null +++ b/codex-rs/config/src/browser_computer_use_requirements.rs @@ -0,0 +1,116 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AllowDenyRequirementToml { + Allow, + Deny, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct BrowserUseOriginPolicyToml { + pub access: Option, + pub downloads: Option, + pub uploads: Option, + pub full_cdp_access: Option, + pub auto_review: Option, + pub persistent_approval: Option, + pub access_approval_lifetime: Option, +} + +impl BrowserUseOriginPolicyToml { + fn is_empty(&self) -> bool { + self.access.is_none() + && self.downloads.is_none() + && self.uploads.is_none() + && self.full_cdp_access.is_none() + && self.auto_review.is_none() + && self.persistent_approval.is_none() + && self.access_approval_lifetime.is_none() + } +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum BrowserUseAccessApprovalLifetimeToml { + Turn, + Thread, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct BrowserUseRequirementsToml { + pub allow_webmcp: Option, + pub allow_history_access: Option, + pub disable_auto_review: Option, + pub allow_global_persistent_approval: Option, + pub default_origin_policy: Option, + pub origins: Option>, +} + +impl BrowserUseRequirementsToml { + pub fn is_empty(&self) -> bool { + self.allow_webmcp.is_none() + && self.allow_history_access.is_none() + && self.disable_auto_review.is_none() + && self.allow_global_persistent_approval.is_none() + && self + .default_origin_policy + .as_ref() + .is_none_or(BrowserUseOriginPolicyToml::is_empty) + && self + .origins + .as_ref() + .is_none_or(|origins| origins.values().all(BrowserUseOriginPolicyToml::is_empty)) + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ComputerUseMacosRequirementsToml { + pub bundle_ids: Option>, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ComputerUseWindowsRequirementsToml { + pub aumids: Option>, + pub exes: Option>, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ComputerUseWindowsExeRequirementToml { + pub publisher_name: String, + pub product_name: String, + pub binary_name: Option, + pub access: AllowDenyRequirementToml, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ComputerUseRequirementsToml { + pub allow_locked_computer_use: Option, + pub allow_persistent_approval: Option, + pub default_app_access: Option, + pub macos: Option, + pub windows: Option, +} + +impl ComputerUseRequirementsToml { + pub fn is_empty(&self) -> bool { + self.allow_locked_computer_use.is_none() + && self.allow_persistent_approval.is_none() + && self.default_app_access.is_none() + && self + .macos + .as_ref() + .is_none_or(|macos| macos.bundle_ids.as_ref().is_none_or(BTreeMap::is_empty)) + && self.windows.as_ref().is_none_or(|windows| { + windows.aumids.as_ref().is_none_or(BTreeMap::is_empty) + && windows.exes.as_ref().is_none_or(Vec::is_empty) + }) + } +} + +#[cfg(test)] +#[path = "browser_computer_use_requirements_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/browser_use_tests.rs b/codex-rs/config/src/browser_use_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..d36ef85820e9eb2107b3eabc4779c942a4e621d9 --- /dev/null +++ b/codex-rs/config/src/browser_use_tests.rs @@ -0,0 +1,52 @@ +use super::*; +use crate::AllowDenyRequirementToml; +use crate::config_toml::ConfigToml; +use pretty_assertions::assert_eq; + +#[test] +fn browser_use_origin_policies_round_trip() { + let config: ConfigToml = toml::from_str( + r#" +[browser_use] +allow_history_access = true + +[browser_use.default_origin_policy] +access = "deny" +downloads = "allow" +uploads = "deny" +full_cdp_access = "allow" + +[browser_use.origins."https://example.com"] +access = "allow" +downloads = "deny" +uploads = "allow" +full_cdp_access = "deny" +"#, + ) + .expect("browser use config should deserialize"); + + let expected = BrowserUseConfigToml { + allow_history_access: Some(true), + default_origin_policy: Some(BrowserUseOriginPolicyConfigToml { + access: Some(AllowDenyRequirementToml::Deny), + downloads: Some(AllowDenyRequirementToml::Allow), + uploads: Some(AllowDenyRequirementToml::Deny), + full_cdp_access: Some(AllowDenyRequirementToml::Allow), + }), + origins: Some(BTreeMap::from([( + "https://example.com".to_string(), + BrowserUseOriginPolicyConfigToml { + access: Some(AllowDenyRequirementToml::Allow), + downloads: Some(AllowDenyRequirementToml::Deny), + uploads: Some(AllowDenyRequirementToml::Allow), + full_cdp_access: Some(AllowDenyRequirementToml::Deny), + }, + )])), + }; + assert_eq!(config.browser_use, Some(expected.clone())); + + let serialized = toml::to_string(&config).expect("browser use config should serialize"); + let reparsed: ConfigToml = + toml::from_str(&serialized).expect("serialized browser use config should deserialize"); + assert_eq!(reparsed.browser_use, Some(expected)); +} diff --git a/codex-rs/config/src/cloud_config_bundle_tests.rs b/codex-rs/config/src/cloud_config_bundle_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..2871c2bfc31384a8c08032e2fe36d375e8adfe16 --- /dev/null +++ b/codex-rs/config/src/cloud_config_bundle_tests.rs @@ -0,0 +1,259 @@ +use super::*; +use crate::AbsolutePathBufGuard; +use crate::ConfigLayerSource; +use crate::ConfigRequirementsToml; +use crate::FilesystemDenyReadPattern; +use crate::SandboxModeRequirement; +use crate::compose_requirements; +use crate::compose_requirements_for_hostname; +use crate::config_requirements::FilesystemRequirementsToml; +use crate::config_requirements::PermissionsRequirementsToml; +use crate::config_toml::ConfigToml; +use crate::types::SandboxWorkspaceWrite; +use codex_protocol::protocol::AskForApproval; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::sync::RwLock; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tempfile::tempdir; + +#[tokio::test] +async fn shared_future_runs_once() { + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let loader = CloudConfigBundleLoader::new(async move { + counter_clone.fetch_add(1, Ordering::SeqCst); + Ok(Some(CloudConfigBundle::default())) + }); + let cloned_loader = loader.clone(); + + let (first, second) = tokio::join!(loader.get(), cloned_loader.get()); + assert_eq!(first, second); + assert_eq!(loader.get().await, first); + assert_eq!(counter.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn getter_returns_latest_result_across_clones() { + let initial_error = CloudConfigBundleLoadError::new( + CloudConfigBundleLoadErrorCode::RequestFailed, + /*status_code*/ None, + "initial load failed", + ); + let latest = Arc::new(RwLock::new(Err(initial_error.clone()))); + let getter_latest = Arc::clone(&latest); + let loader = CloudConfigBundleLoader::from_getter(move || { + let latest = Arc::clone(&getter_latest); + async move { latest.read().expect("bundle state lock").clone() } + }); + let cloned_loader = loader.clone(); + + assert_eq!(loader.get().await, Err(initial_error)); + + let bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "managed".to_string(), + name: "Managed".to_string(), + contents: "model = \"managed\"".to_string(), + }], + }, + ..Default::default() + }; + *latest.write().expect("bundle state lock") = Ok(Some(bundle.clone())); + + assert_eq!(cloned_loader.get().await, Ok(Some(bundle))); + assert_eq!(CloudConfigBundleLoader::default().get().await, Ok(None)); + + *latest.write().expect("bundle state lock") = Ok(None); + + assert_eq!(loader.get().await, Ok(None)); +} + +#[test] +fn bundle_layers_preserve_enterprise_managed_bucket_order() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let layers = CloudConfigBundleLayers::from_bundle( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![ + CloudConfigFragment { + id: "cfg_high".to_string(), + name: "High config".to_string(), + contents: "model = \"high\"".to_string(), + }, + CloudConfigFragment { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + contents: "model = \"low\"".to_string(), + }, + ], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![ + CloudRequirementsFragment { + id: "req_high".to_string(), + name: "High requirements".to_string(), + contents: "allowed_approval_policies = [\"on-request\"]".to_string(), + }, + CloudRequirementsFragment { + id: "req_low".to_string(), + name: "Low requirements".to_string(), + contents: "allowed_approval_policies = [\"never\"]".to_string(), + }, + ], + }, + }, + &base_dir, + ) + .expect("bundle should be converted into layers"); + + assert_eq!( + layers + .enterprise_managed_config + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::EnterpriseManaged { + id: "cfg_low".to_string(), + name: "Low config".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_high".to_string(), + name: "High config".to_string(), + }, + ] + ); + assert_eq!( + compose_requirements(layers.enterprise_managed_requirements) + .expect("requirements should compose") + .expect("requirements should be present") + .into_toml(), + ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), + ..Default::default() + } + ); +} + +#[test] +fn bundle_layers_can_strict_validate_enterprise_managed_config() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let err = CloudConfigBundleLayers::from_bundle_strict_config( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + contents: "unknown_key = true".to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: Vec::new(), + }, + }, + &base_dir, + ) + .expect_err("strict config should reject unknown fields"); + + assert_eq!( + err, + CloudConfigLayerError::Invalid { + fragment: crate::CloudConfigFragmentSource { + id: "cfg".to_string(), + name: "Cloud config".to_string(), + }, + message: "unknown configuration field `unknown_key`".to_string(), + } + ); +} + +#[test] +fn bundle_layers_resolve_paths_and_requirements_for_the_execution_host() { + let temp_dir = tempdir().expect("temporary directories"); + let executor_home = temp_dir.path().join("executor-home"); + let executor_codex_home = AbsolutePathBuf::from_absolute_path(executor_home.join(".codex")) + .expect("absolute executor Codex home"); + let bundle = CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + enterprise_managed: vec![CloudConfigFragment { + id: "config".to_string(), + name: "Executor config".to_string(), + contents: r#" +[sandbox_workspace_write] +writable_roots = ["~/cloud-root", "./relative-root"] +"# + .to_string(), + }], + }, + requirements_toml: CloudRequirementsTomlBundle { + enterprise_managed: vec![CloudRequirementsFragment { + id: "requirements".to_string(), + name: "Executor requirements".to_string(), + contents: r#" +[permissions.filesystem] +deny_read = ["~/private"] + +[[remote_sandbox_config]] +hostname_patterns = ["executor-*"] +allowed_sandbox_modes = ["read-only"] +"# + .to_string(), + }], + }, + }; + + let (config, requirements) = AbsolutePathBufGuard::with_home_directory(&executor_home, || { + let layers = + CloudConfigBundleLayers::from_bundle_strict_config(bundle, &executor_codex_home) + .expect("executor bundle should convert into layers"); + let config: ConfigToml = layers.enterprise_managed_config[0] + .config + .clone() + .try_into() + .expect("deserialize executor config"); + let requirements = compose_requirements_for_hostname( + layers.enterprise_managed_requirements, + Some("executor-01"), + ) + .expect("compose executor requirements") + .expect("executor requirements should be present") + .into_toml(); + (config, requirements) + }); + + assert_eq!( + config.sandbox_workspace_write, + Some(SandboxWorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::from_absolute_path(executor_home.join("cloud-root")) + .expect("absolute cloud root"), + AbsolutePathBuf::from_absolute_path( + executor_codex_home.as_path().join("relative-root"), + ) + .expect("absolute relative root"), + ], + ..Default::default() + }) + ); + assert_eq!( + requirements, + ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), + permissions: Some(PermissionsRequirementsToml { + filesystem: Some(FilesystemRequirementsToml { + deny_read: Some(vec![FilesystemDenyReadPattern::from( + AbsolutePathBuf::from_absolute_path(executor_home.join("private")) + .expect("absolute private root"), + )]), + }), + ..Default::default() + }), + ..Default::default() + } + ); +} diff --git a/codex-rs/config/src/cloud_config_layers.rs b/codex-rs/config/src/cloud_config_layers.rs new file mode 100644 index 0000000000000000000000000000000000000000..f6fcd40b4aad0321b29283ccc300b6617d8dd885 --- /dev/null +++ b/codex-rs/config/src/cloud_config_layers.rs @@ -0,0 +1,151 @@ +//! Conversion from cloud-delivered config TOML fragments into config stack layers. +//! +//! Backend fragments arrive in backend priority order. This module parses each +//! fragment, resolves relative path fields against the cloud config base +//! directory, and returns layers in `ConfigLayerStack` order. + +use crate::ConfigLayerEntry; +use crate::ConfigLayerSource; +use crate::TomlValue; +use crate::config_toml::ConfigToml; +use crate::loader::resolve_relative_paths_in_config_toml; +use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::io; +use thiserror::Error; + +/// Config fragment delivered by the cloud config bundle. +/// +/// The bundle orders fragments from highest precedence to lowest precedence. +/// This module returns config layers in stack order, so callers can append the +/// result between system and user config without re-sorting. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigFragment { + pub id: String, + pub name: String, + pub contents: String, +} + +impl CloudConfigFragment { + fn source_ref(&self) -> CloudConfigFragmentSource { + CloudConfigFragmentSource { + id: self.id.clone(), + name: self.name.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloudConfigFragmentSource { + pub id: String, + pub name: String, +} + +impl fmt::Display for CloudConfigFragmentSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CloudConfigLayerError { + #[error("failed to parse cloud config fragment {fragment}: {message}")] + Parse { + fragment: CloudConfigFragmentSource, + message: String, + }, + #[error("invalid cloud config fragment {fragment}: {message}")] + Invalid { + fragment: CloudConfigFragmentSource, + message: String, + }, +} + +pub fn cloud_config_layers_from_fragments( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ false) +} + +pub(crate) fn cloud_config_layers_from_fragments_strict( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true) +} + +fn cloud_config_layers_from_fragments_impl( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, + strict_config: bool, +) -> Result, CloudConfigLayerError> { + let mut layers = Vec::new(); + for fragment in fragments { + let source_ref = fragment.source_ref(); + let raw_toml = fragment.contents; + let value: TomlValue = + toml::from_str(&raw_toml).map_err(|err| CloudConfigLayerError::Parse { + fragment: source_ref.clone(), + message: err.to_string(), + })?; + if strict_config { + validate_fragment_strictly(&source_ref, &raw_toml, &value, base_dir)?; + } + let resolved = + resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| { + CloudConfigLayerError::Invalid { + fragment: source_ref.clone(), + message: err.to_string(), + } + })?; + layers.push(ConfigLayerEntry::new_with_raw_toml( + ConfigLayerSource::EnterpriseManaged { + id: fragment.id, + name: fragment.name, + }, + resolved, + raw_toml, + base_dir.clone(), + )); + } + + // Bundle fragments arrive highest-priority first, while ConfigLayerStack + // folds lowest-priority to highest-priority. + layers.reverse(); + Ok(layers) +} + +fn validate_fragment_strictly( + source_ref: &CloudConfigFragmentSource, + raw_toml: &str, + value: &TomlValue, + base_dir: &AbsolutePathBuf, +) -> Result<(), CloudConfigLayerError> { + let _guard = AbsolutePathBufGuard::new(base_dir.as_path()); + if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::< + ConfigToml, + >(&source_ref.to_string(), raw_toml, value.clone()) + { + return Err(CloudConfigLayerError::Invalid { + fragment: source_ref.clone(), + message: config_error.message, + }); + } + + Ok(()) +} + +impl From for io::Error { + fn from(error: CloudConfigLayerError) -> Self { + io::Error::new(io::ErrorKind::InvalidData, error) + } +} + +#[cfg(test)] +#[path = "cloud_config_layers_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/codex_home_symlink.rs b/codex-rs/config/src/codex_home_symlink.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf544f49204b4aba5f2d7b4f64c42c5f23c45b5f --- /dev/null +++ b/codex-rs/config/src/codex_home_symlink.rs @@ -0,0 +1,28 @@ +//! Resolves the host's symlink opt-out from its already-loaded user config. + +use crate::ConfigLayerSource; +use crate::ConfigLayerStack; +use codex_utils_absolute_path::AbsolutePathBuf; + +/// Returns the opted-in home without consulting project config or rereading config files. +pub fn allowed_symlinked_codex_home( + config_layer_stack: &ConfigLayerStack, + codex_home: &AbsolutePathBuf, +) -> Option { + let enabled = config_layer_stack + .layers_low_to_high() + .find(|layer| matches!(layer.name, ConfigLayerSource::User { profile: None, .. }))? + .config + .get("allow_symlinked_codex_home")? + .as_bool()?; + if !enabled { + return None; + } + + // Preserve a configured alias only when lexical normalization did not change its target. + let alias = std::env::var_os("CODEX_HOME") + .filter(|path| !path.is_empty()) + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .filter(|path| path.canonicalize().ok().as_ref() == Some(codex_home)); + Some(alias.unwrap_or_else(|| codex_home.clone())) +} diff --git a/codex-rs/config/src/computer_use.rs b/codex-rs/config/src/computer_use.rs new file mode 100644 index 0000000000000000000000000000000000000000..6523d19bedea8830ce626e1b5eef01b1d2107958 --- /dev/null +++ b/codex-rs/config/src/computer_use.rs @@ -0,0 +1,39 @@ +use crate::AllowDenyRequirementToml; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ComputerUseConfigToml { + pub default_app_access: Option, + pub macos: Option, + pub windows: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ComputerUseMacosConfigToml { + pub bundle_ids: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ComputerUseWindowsConfigToml { + pub aumids: Option>, + pub exes: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ComputerUseWindowsExeConfigToml { + pub publisher_name: String, + pub product_name: String, + pub binary_name: Option, + pub access: AllowDenyRequirementToml, +} + +#[cfg(test)] +#[path = "computer_use_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/computer_use_tests.rs b/codex-rs/config/src/computer_use_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..96505c43882a9c2fbcc4e983007fdbff08f7eecd --- /dev/null +++ b/codex-rs/config/src/computer_use_tests.rs @@ -0,0 +1,54 @@ +use super::*; +use crate::config_toml::ConfigToml; +use pretty_assertions::assert_eq; + +#[test] +fn computer_use_config_round_trips() { + let config: ConfigToml = toml::from_str( + r#" +[computer_use] +default_app_access = "deny" + +[computer_use.macos.bundle_ids] +"com.apple.Safari" = "allow" + +[computer_use.windows.aumids] +"Microsoft.Paint_8wekyb3d8bbwe!App" = "deny" + +[[computer_use.windows.exes]] +publisher_name = "CN=Google LLC" +product_name = "Google Chrome" +binary_name = "chrome.exe" +access = "allow" +"#, + ) + .expect("computer use config should deserialize"); + + let expected = ComputerUseConfigToml { + default_app_access: Some(AllowDenyRequirementToml::Deny), + macos: Some(ComputerUseMacosConfigToml { + bundle_ids: Some(BTreeMap::from([( + "com.apple.Safari".to_string(), + AllowDenyRequirementToml::Allow, + )])), + }), + windows: Some(ComputerUseWindowsConfigToml { + aumids: Some(BTreeMap::from([( + "Microsoft.Paint_8wekyb3d8bbwe!App".to_string(), + AllowDenyRequirementToml::Deny, + )])), + exes: Some(vec![ComputerUseWindowsExeConfigToml { + publisher_name: "CN=Google LLC".to_string(), + product_name: "Google Chrome".to_string(), + binary_name: Some("chrome.exe".to_string()), + access: AllowDenyRequirementToml::Allow, + }]), + }), + }; + assert_eq!(config.computer_use, Some(expected.clone())); + + let serialized = toml::to_string(&config).expect("computer use config should serialize"); + let reparsed: ConfigToml = + toml::from_str(&serialized).expect("serialized computer use config should deserialize"); + assert_eq!(reparsed.computer_use, Some(expected)); +} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs new file mode 100644 index 0000000000000000000000000000000000000000..821bbaef2a1ccf24f44516a06f61e4e1243d4452 --- /dev/null +++ b/codex-rs/config/src/config_requirements.rs @@ -0,0 +1,4895 @@ +use crate::ApplicationRequirementsToml; +use codex_features::FeatureToml; +use codex_model_provider_info::ModelProviderInfo; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; +use serde::de::Error as _; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::convert::Infallible; +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; +use wildmatch::WildMatchPattern; + +use super::requirements_exec_policy::RequirementsExecPolicyToml; +use crate::Constrained; +use crate::ConstraintError; +use crate::InAppBrowserRequirementsToml; +use crate::ManagedAuthPolicy; +use crate::ManagedHooksRequirementsToml; +use crate::McpServerRequirement; +use crate::PluginRequirementsToml; +use crate::RequirementsExecPolicy; +use crate::browser_computer_use_requirements::BrowserUseRequirementsToml; +use crate::browser_computer_use_requirements::ComputerUseRequirementsToml; +use crate::config_toml::ConfigToml; +use crate::mcp_requirements::validate_mcp_server_requirement; +use crate::mcp_types::AppToolApproval; +use crate::permissions_toml::PermissionProfileToml; +use crate::types::AuthCredentialsStoreMode; +use crate::types::FeedbackConfigToml; +use crate::types::WindowsSandboxModeToml; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RequirementSource { + Unknown, + MdmManagedPreferences { + domain: String, + key: String, + }, + /// Multiple requirements layers contributed to the final value. Sources are + /// stored highest-priority first, matching the order surfaced in errors. + Composite { + sources: Vec, + }, + /// A backend-delivered enterprise-managed layer. `id` is the stable backend + /// identifier; `name` is the admin-facing display name. + EnterpriseManaged { + id: String, + name: String, + }, + SystemRequirementsToml { + file: AbsolutePathBuf, + }, + LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf, + }, + LegacyManagedConfigTomlFromMdm, +} + +impl RequirementSource { + pub fn composite(sources: impl IntoIterator) -> Self { + let mut flattened = Vec::new(); + for source in sources { + source.append_to_composite(&mut flattened); + } + + match flattened.len() { + 0 => RequirementSource::Unknown, + 1 => flattened.remove(0), + _ => RequirementSource::Composite { sources: flattened }, + } + } + + fn append_to_composite(self, flattened: &mut Vec) { + match self { + RequirementSource::Composite { sources } => { + for source in sources { + source.append_to_composite(flattened); + } + } + source => { + if !flattened.contains(&source) { + flattened.push(source); + } + } + } + } +} + +impl fmt::Display for RequirementSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RequirementSource::Unknown => write!(f, ""), + RequirementSource::MdmManagedPreferences { domain, key } => { + write!(f, "MDM {domain}:{key}") + } + RequirementSource::Composite { sources } => { + write!(f, "requirements layers: ")?; + for (index, source) in sources.iter().enumerate() { + if index > 0 { + write!(f, ", ")?; + } + write!(f, "{source}")?; + } + Ok(()) + } + RequirementSource::EnterpriseManaged { id, name } => { + write!(f, "enterprise-managed requirements {name} ({id})") + } + RequirementSource::SystemRequirementsToml { file } => { + write!(f, "{}", file.as_path().display()) + } + RequirementSource::LegacyManagedConfigTomlFromFile { file } => { + write!(f, "{}", file.as_path().display()) + } + RequirementSource::LegacyManagedConfigTomlFromMdm => { + write!(f, "MDM managed_config.toml (legacy)") + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConstrainedWithSource { + pub value: Constrained, + pub source: Option, +} + +impl ConstrainedWithSource { + pub fn new(value: Constrained, source: Option) -> Self { + Self { value, source } + } +} + +impl std::ops::Deref for ConstrainedWithSource { + type Target = Constrained; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl std::ops::DerefMut for ConstrainedWithSource { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.value + } +} + +/// Normalized version of [`ConfigRequirementsToml`] after deserialization and +/// normalization. +#[derive(Debug, Clone, PartialEq)] +pub struct ConfigRequirements { + pub allowed_login_methods: Option>>, + pub allowed_chatgpt_workspaces: Option>>, + pub cli_auth_credentials_store: Option>, + pub chatgpt_base_url: Option>, + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub model_provider: Option>, + pub model_providers: Option>>, + pub check_for_update_on_startup: Option>, + pub allow_login_shell: Option>, + pub feedback: Option>, + pub approval_policy: ConstrainedWithSource, + pub approvals_reviewer: ConstrainedWithSource, + pub auto_review_required_models: Option>>, + pub permission_profile: ConstrainedWithSource, + pub windows_sandbox_mode: ConstrainedWithSource>, + pub windows_sandbox_private_desktop: Option>, + pub web_search_mode: ConstrainedWithSource, + pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, + pub allow_remote_control: Option>, + pub computer_use: Option>, + pub feature_requirements: Option>, + pub managed_hooks: Option>, + pub mcp_servers: Option>>, + pub plugins: Option>>, + pub marketplaces: Option>, + pub exec_policy: Option>, + pub enforce_residency: ConstrainedWithSource>, + /// Managed network constraints derived from requirements. + pub network: Option>, + pub application: Option>, + /// Managed filesystem constraints derived from requirements. + pub filesystem: Option>, + /// Managed instructions included independently of ordinary developer instructions. + pub additional_developer_instructions: Option>, + /// Source for the managed guardian policy config, when one is configured. + pub guardian_policy_config_source: Option, +} + +impl Default for ConfigRequirements { + fn default() -> Self { + Self { + allowed_login_methods: None, + allowed_chatgpt_workspaces: None, + cli_auth_credentials_store: None, + chatgpt_base_url: None, + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + model_provider: None, + model_providers: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, + approval_policy: ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + approvals_reviewer: ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + auto_review_required_models: None, + permission_profile: ConstrainedWithSource::new( + Constrained::allow_any(PermissionProfile::read_only()), + /*source*/ None, + ), + windows_sandbox_mode: ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + windows_sandbox_private_desktop: None, + web_search_mode: ConstrainedWithSource::new( + Constrained::allow_any(WebSearchMode::Cached), + /*source*/ None, + ), + allow_managed_hooks_only: None, + allow_appshots: None, + allow_remote_control: None, + computer_use: None, + feature_requirements: None, + managed_hooks: None, + mcp_servers: None, + plugins: None, + marketplaces: None, + exec_policy: None, + enforce_residency: ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + network: None, + application: None, + filesystem: None, + additional_developer_instructions: None, + guardian_policy_config_source: None, + } + } +} + +impl ConfigRequirements { + /// Returns whether a model slug or its supported provider alias requires auto-review. + pub fn auto_review_required_for_model(&self, model: &str) -> bool { + let Some(protected_models) = self.auto_review_required_models.as_ref() else { + return false; + }; + + let model = match model.split_once('/') { + Some((namespace, suffix)) + if !namespace.is_empty() + && !suffix.contains('/') + && namespace.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '_' || character == '-' + }) => + { + suffix + } + Some(_) => return false, + None => model, + }; + + protected_models.value.contains(model) + } + + pub fn managed_auth_policy(&self) -> ManagedAuthPolicy { + ManagedAuthPolicy { + allowed_login_methods: self + .allowed_login_methods + .as_ref() + .map(|allowed| allowed.value.clone()), + allowed_chatgpt_workspaces: self.allowed_chatgpt_workspaces.as_ref().map(|allowed| { + allowed + .value + .iter() + .map(|workspace| workspace.trim()) + .filter(|workspace| !workspace.is_empty()) + .map(str::to_string) + .collect() + }), + } + } + + pub fn exec_policy_source(&self) -> Option<&RequirementSource> { + self.exec_policy.as_ref().map(|policy| &policy.source) + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct MarketplaceRequirementsToml { + pub restrict_to_allowed_sources: Option, + #[serde(default)] + pub allowed_sources: BTreeMap, +} + +impl MarketplaceRequirementsToml { + pub fn is_empty(&self) -> bool { + self.restrict_to_allowed_sources.is_none() && self.allowed_sources.is_empty() + } +} + +/// Raw marketplace source rule whose active fields are interpreted after +/// requirements composition. +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct MarketplaceAllowedSourceToml { + pub source: Option, + pub url: Option, + #[serde(rename = "ref")] + pub ref_name: Option, + pub host_pattern: Option, + pub path: Option, +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MarketplaceAllowedSourceKind { + Git, + HostPattern, + Local, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NetworkRequirementsToml { + pub enabled: Option, + pub http_port: Option, + pub socks_port: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + pub domains: Option, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + pub managed_allowed_domains_only: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, + /// Requirements-only header injections. These annotate matching requests + /// without changing whether non-matching requests are allowed. + pub header_injections: Option>, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct NetworkHeaderInjectionToml { + pub host: String, + pub methods: Vec, + pub path_prefixes: Vec, + pub headers: BTreeMap, +} + +impl std::fmt::Debug for NetworkHeaderInjectionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NetworkHeaderInjectionToml") + .field("host", &self.host) + .field("methods", &self.methods) + .field("path_prefixes", &self.path_prefixes) + .field("header_names", &self.headers.keys().collect::>()) + .finish() + } +} + +#[derive(Deserialize)] +struct RawNetworkRequirementsToml { + enabled: Option, + http_port: Option, + socks_port: Option, + allow_upstream_proxy: Option, + dangerously_allow_non_loopback_proxy: Option, + dangerously_allow_all_unix_sockets: Option, + domains: Option, + #[serde(default)] + allowed_domains: Option>, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + managed_allowed_domains_only: Option, + #[serde(default)] + denied_domains: Option>, + unix_sockets: Option, + #[serde(default)] + allow_unix_sockets: Option>, + allow_local_binding: Option, + header_injections: Option>, +} + +impl<'de> Deserialize<'de> for NetworkRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawNetworkRequirementsToml::deserialize(deserializer)?; + let RawNetworkRequirementsToml { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + allowed_domains, + managed_allowed_domains_only, + denied_domains, + unix_sockets, + allow_unix_sockets, + allow_local_binding, + header_injections, + } = raw; + + if domains.is_some() && (allowed_domains.is_some() || denied_domains.is_some()) { + return Err(D::Error::custom( + "`experimental_network.domains` cannot be combined with legacy `allowed_domains` or `denied_domains`", + )); + } + + if unix_sockets.is_some() && allow_unix_sockets.is_some() { + return Err(D::Error::custom( + "`experimental_network.unix_sockets` cannot be combined with legacy `allow_unix_sockets`", + )); + } + + Ok(Self { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains: domains + .or_else(|| legacy_domain_permissions_from_lists(allowed_domains, denied_domains)), + managed_allowed_domains_only, + unix_sockets: unix_sockets + .or_else(|| legacy_unix_socket_permissions_from_list(allow_unix_sockets)), + allow_local_binding, + header_injections, + }) + } +} + +/// Legacy list normalization is intentionally lossy: explicit empty legacy +/// lists are treated as unset when converted to the canonical network +/// permission shape. +fn legacy_domain_permissions_from_lists( + allowed_domains: Option>, + denied_domains: Option>, +) -> Option { + let mut entries = BTreeMap::new(); + + for pattern in allowed_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Allow); + } + + for pattern in denied_domains.unwrap_or_default() { + entries.insert(pattern, NetworkDomainPermissionToml::Deny); + } + + (!entries.is_empty()).then_some(NetworkDomainPermissionsToml { entries }) +} + +fn legacy_unix_socket_permissions_from_list( + allow_unix_sockets: Option>, +) -> Option { + let entries = allow_unix_sockets + .unwrap_or_default() + .into_iter() + .map(|path| (path, NetworkUnixSocketPermissionToml::Allow)) + .collect::>(); + + (!entries.is_empty()).then_some(NetworkUnixSocketPermissionsToml { entries }) +} + +/// Normalized network constraints derived from requirements TOML. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct NetworkConstraints { + pub enabled: Option, + pub http_port: Option, + pub socks_port: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + pub domains: Option, + /// When true, only managed `allowed_domains` are respected while managed + /// network enforcement is active. User allowlist entries are ignored. + pub managed_allowed_domains_only: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, + pub header_injections: Option>, +} + +impl<'de> Deserialize<'de> for NetworkConstraints { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let requirements = NetworkRequirementsToml::deserialize(deserializer)?; + Ok(requirements.into()) + } +} + +impl From for NetworkConstraints { + fn from(value: NetworkRequirementsToml) -> Self { + let NetworkRequirementsToml { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + managed_allowed_domains_only, + unix_sockets, + allow_local_binding, + header_injections, + } = value; + Self { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + managed_allowed_domains_only, + unix_sockets, + allow_local_binding, + header_injections, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FilesystemRequirementsToml { + pub deny_read: Option>, +} + +#[derive(Deserialize)] +struct RawFilesystemRequirementsToml { + deny_read: Option>, + description: Option, + extends: Option, + workspace_roots: Option, + filesystem: Option, + network: Option, +} + +impl<'de> Deserialize<'de> for FilesystemRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawFilesystemRequirementsToml::deserialize(deserializer)?; + let RawFilesystemRequirementsToml { + deny_read, + description, + extends, + workspace_roots, + filesystem, + network, + } = raw; + + if description.is_some() + || extends.is_some() + || workspace_roots.is_some() + || filesystem.is_some() + || network.is_some() + { + return Err(D::Error::custom( + "`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile", + )); + } + + Ok(Self { deny_read }) + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct PermissionsRequirementsToml { + pub filesystem: Option, + // For legacy reasons, `filesystem` stays reserved for requirements-level + // filesystem constraints and cannot name a profile. + #[serde(default, flatten)] + pub profiles: BTreeMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FilesystemConstraints { + pub deny_read: Vec, +} + +impl From for FilesystemConstraints { + fn from(value: PermissionsRequirementsToml) -> Self { + let deny_read = value + .filesystem + .and_then(|filesystem| filesystem.deny_read) + .unwrap_or_default(); + Self { deny_read } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct FilesystemDenyReadPattern(String); + +impl FilesystemDenyReadPattern { + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn contains_glob(&self) -> bool { + self.0.chars().any(is_glob_metacharacter) + } + + pub fn from_input(input: &str) -> Result { + codex_utils_path_uri::PathUri::validate_config_path_text( + input, + crate::path_context::convention(), + ) + .map_err(|error| error.to_string())?; + if !input.chars().any(is_glob_metacharacter) { + let path = deserialize_absolute_path(input)?; + validate_literal_denial_path(&path)?; + return Ok(Self(path)); + } + + let (directory_prefix, suffix) = split_glob_pattern(input); + if crate::path_context::convention() == codex_utils_path_uri::PathConvention::Windows + && matches!(input.as_bytes(), [b'/' | b'\\', b'/' | b'\\', ..]) + && !matches!( + directory_prefix.as_bytes(), + [b'/' | b'\\', b'/' | b'\\', ..] + ) + { + return Err( + "filesystem denial glob requires a literal UNC server and share".to_string(), + ); + } + let normalized_prefix = if directory_prefix.is_empty() { + deserialize_absolute_path(".")? + } else { + deserialize_absolute_path(directory_prefix)? + }; + // The prefix is literal even when supplied home/base facts contain + // glob syntax. Reject it before appending the user's pattern suffix. + validate_literal_denial_path(&normalized_prefix)?; + let normalized = if suffix.is_empty() { + normalized_prefix + } else if normalized_prefix == "/" { + format!("/{suffix}") + } else { + format!("{normalized_prefix}/{suffix}") + }; + codex_utils_path_uri::PathUri::validate_config_path_text( + &normalized, + crate::path_context::convention(), + ) + .map_err(|error| error.to_string())?; + Ok(Self(normalized)) + } +} + +impl From for FilesystemDenyReadPattern { + fn from(value: AbsolutePathBuf) -> Self { + Self(value.to_string_lossy().into_owned()) + } +} + +impl<'de> Deserialize<'de> for FilesystemDenyReadPattern { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let input = String::deserialize(deserializer)?; + Self::from_input(&input).map_err(D::Error::custom) + } +} + +fn validate_literal_denial_path(path: &str) -> Result<(), String> { + let convention = crate::path_context::convention(); + codex_utils_path_uri::LegacyAppPathString::from_string(path) + .to_path_uri(convention) + .and_then(|path| path.validate_glob_directory(convention)) + .map_err(|error| error.to_string()) +} + +fn deserialize_absolute_path(input: &str) -> Result { + crate::path_context::resolve(input) +} + +fn split_glob_pattern(input: &str) -> (&str, &str) { + let Some(first_glob) = input.find(is_glob_metacharacter) else { + return ("", input); + }; + let separator_index = input[..first_glob] + .char_indices() + .rev() + .find(|(_, ch)| is_path_separator(*ch)) + .map(|(index, _)| index); + + match separator_index { + Some(0) => ("/", &input[1..]), + Some(index) + if crate::path_context::convention() + == codex_utils_path_uri::PathConvention::Windows + && index == 2 + && input.as_bytes().get(1) == Some(&b':') + && input.as_bytes().get(2).is_some() => + { + (&input[..=index], &input[index + 1..]) + } + Some(index) => (&input[..index], &input[index + 1..]), + None => ("", input), + } +} + +fn is_path_separator(ch: char) -> bool { + if crate::path_context::convention() == codex_utils_path_uri::PathConvention::Windows { + ch == '/' || ch == '\\' + } else { + ch == '/' + } +} + +fn is_glob_metacharacter(ch: char) -> bool { + matches!(ch, '*' | '?' | '[') +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[serde(rename_all = "snake_case")] +pub enum WebSearchModeRequirement { + Disabled, + Cached, + Indexed, + Live, +} + +impl From for WebSearchModeRequirement { + fn from(mode: WebSearchMode) -> Self { + match mode { + WebSearchMode::Disabled => WebSearchModeRequirement::Disabled, + WebSearchMode::Cached => WebSearchModeRequirement::Cached, + WebSearchMode::Indexed => WebSearchModeRequirement::Indexed, + WebSearchMode::Live => WebSearchModeRequirement::Live, + } + } +} + +impl From for WebSearchMode { + fn from(mode: WebSearchModeRequirement) -> Self { + match mode { + WebSearchModeRequirement::Disabled => WebSearchMode::Disabled, + WebSearchModeRequirement::Cached => WebSearchMode::Cached, + WebSearchModeRequirement::Indexed => WebSearchMode::Indexed, + WebSearchModeRequirement::Live => WebSearchMode::Live, + } + } +} + +impl fmt::Display for WebSearchModeRequirement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WebSearchModeRequirement::Disabled => write!(f, "disabled"), + WebSearchModeRequirement::Cached => write!(f, "cached"), + WebSearchModeRequirement::Indexed => write!(f, "indexed"), + WebSearchModeRequirement::Live => write!(f, "live"), + } + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct WindowsRequirementsToml { + pub allowed_sandbox_implementations: Option>, + pub sandbox_private_desktop: Option, +} + +impl WindowsRequirementsToml { + pub fn is_empty(&self) -> bool { + self.allowed_sandbox_implementations.is_none() && self.sandbox_private_desktop.is_none() + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct FeatureRequirementsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FeatureRequirementsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppToolRequirementToml { + pub approval_mode: Option, + /// Opt-in analytics extraction for this exact tool, not a tool argument. + /// The highest-priority rule wins as a whole, including unsupported formats. + pub analytics_result_source: Option, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AppToolResultSourceRequirementToml { + /// Result format to parse; currently only `detailed_message_search_v1` is supported. + pub format: AppToolResultSourceFormat, + /// Source kind emitted alongside each extracted ID. + #[serde(rename = "type")] + pub source_type: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppToolResultSourceFormat { + DetailedMessageSearchV1, + /// Keep unknown formats so higher-priority rules still override lower ones. + Unknown(String), +} + +impl FromStr for AppToolResultSourceFormat { + type Err = Infallible; + + fn from_str(value: &str) -> Result { + Ok(match value { + "detailed_message_search_v1" => Self::DetailedMessageSearchV1, + _ => Self::Unknown(value.to_string()), + }) + } +} + +impl<'de> Deserialize<'de> for AppToolResultSourceFormat { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} + +impl AppToolRequirementToml { + pub fn is_empty(&self) -> bool { + self.approval_mode.is_none() && self.analytics_result_source.is_none() + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppToolsRequirementsToml { + #[serde(default, flatten)] + pub tools: BTreeMap, +} + +impl AppToolsRequirementsToml { + pub fn is_empty(&self) -> bool { + self.tools.values().all(AppToolRequirementToml::is_empty) + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppRequirementToml { + pub enabled: Option, + pub tools: Option, +} + +impl AppRequirementToml { + pub fn is_empty(&self) -> bool { + self.enabled.is_none() + && self + .tools + .as_ref() + .is_none_or(AppToolsRequirementsToml::is_empty) + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppsRequirementsToml { + #[serde(default, flatten)] + pub apps: BTreeMap, +} + +impl AppsRequirementsToml { + pub fn is_empty(&self) -> bool { + self.apps.values().all(AppRequirementToml::is_empty) + } +} + +/// Merge app requirements from a lower-precedence source into an existing higher-precedence set. +/// This lets managed sources (for example Cloud/MDM) enforce setting disablement across layers, +/// while exact tool approval settings keep the higher-precedence value when present. +pub(crate) fn merge_app_requirements_descending( + base: &mut AppsRequirementsToml, + incoming: AppsRequirementsToml, +) { + for (app_id, incoming_requirement) in incoming.apps { + let base_requirement = base.apps.entry(app_id).or_default(); + let higher_precedence = base_requirement.enabled; + let lower_precedence = incoming_requirement.enabled; + base_requirement.enabled = + if higher_precedence == Some(false) || lower_precedence == Some(false) { + Some(false) + } else { + higher_precedence.or(lower_precedence) + }; + + let Some(incoming_tools) = incoming_requirement.tools else { + continue; + }; + let base_tools = base_requirement.tools.get_or_insert_with(Default::default); + for (tool_name, incoming_tool) in incoming_tools.tools { + let base_tool = base_tools.tools.entry(tool_name).or_default(); + if base_tool.approval_mode.is_none() { + base_tool.approval_mode = incoming_tool.approval_mode; + } + if base_tool.analytics_result_source.is_none() { + base_tool.analytics_result_source = incoming_tool.analytics_result_source; + } + } + } +} + +/// Base config deserialized from system `requirements.toml` or MDM. +#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +pub struct ConfigRequirementsToml { + pub allowed_login_methods: Option>, + pub allowed_chatgpt_workspaces: Option>, + pub cli_auth_credentials_store: Option, + pub chatgpt_base_url: Option, + pub sqlite_home: Option, + pub log_dir: Option, + pub model_catalog_json: Option, + /// Exact provider selection, overriding local and session configuration. + pub model_provider: Option, + /// Complete provider definitions; each entry replaces the configured provider. + pub model_providers: Option>, + pub check_for_update_on_startup: Option, + pub allow_login_shell: Option, + pub feedback: Option, + pub allowed_approval_policies: Option>, + pub allowed_approvals_reviewers: Option>, + pub allowed_sandbox_modes: Option>, + pub allowed_permission_profiles: Option>, + pub default_permissions: Option, + pub remote_sandbox_config: Option>, + pub allowed_web_search_modes: Option>, + pub allow_managed_hooks_only: Option, + pub allow_browser_and_computer_use: Option, + pub allow_appshots: Option, + pub allow_remote_control: Option, + pub computer_use: Option, + pub browser_use: Option, + pub in_app_browser: Option, + pub windows: Option, + #[serde(rename = "features", alias = "feature_requirements")] + pub feature_requirements: Option, + pub hooks: Option, + pub mcp_servers: Option>, + pub plugins: Option>, + pub marketplaces: Option, + pub apps: Option, + pub rules: Option, + pub enforce_residency: Option, + #[serde(rename = "experimental_network")] + pub network: Option, + pub application: Option, + pub permissions: Option, + pub auto_review: Option, + pub models: Option, + pub additional_developer_instructions: Option, + pub guardian_policy_config: Option, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AutoReviewRequirementsToml { + pub required_on_models: Option>, + pub ignore_rules: Option>, +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ModelsRequirementsToml { + pub new_thread: Option, +} + +impl ModelsRequirementsToml { + fn is_empty(&self) -> bool { + self.new_thread + .as_ref() + .is_none_or(NewThreadModelDefaultsToml::is_empty) + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct NewThreadModelDefaultsToml { + pub model: Option, + pub model_reasoning_effort: Option, + pub service_tier: Option, +} + +impl NewThreadModelDefaultsToml { + fn is_empty(&self) -> bool { + self.model.is_none() && self.model_reasoning_effort.is_none() && self.service_tier.is_none() + } +} + +#[derive(Deserialize, Debug, Clone, PartialEq)] +pub struct RemoteSandboxConfigToml { + pub hostname_patterns: Vec, + pub allowed_sandbox_modes: Vec, +} + +/// Value paired with the requirement source it came from, for better error +/// messages. +#[derive(Debug, Clone, PartialEq)] +pub struct Sourced { + pub value: T, + pub source: RequirementSource, +} + +impl Sourced { + pub fn new(value: T, source: RequirementSource) -> Self { + Self { value, source } + } +} + +impl std::ops::Deref for Sourced { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ConfigRequirementsWithSources { + pub allowed_login_methods: Option>>, + pub allowed_chatgpt_workspaces: Option>>, + pub cli_auth_credentials_store: Option>, + pub chatgpt_base_url: Option>, + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub model_provider: Option>, + pub model_providers: Option>>, + pub check_for_update_on_startup: Option>, + pub allow_login_shell: Option>, + pub feedback: Option>, + pub allowed_approval_policies: Option>>, + pub allowed_approvals_reviewers: Option>>, + pub allowed_sandbox_modes: Option>>, + pub allowed_permission_profiles: Option>>, + pub default_permissions: Option>, + pub allowed_web_search_modes: Option>>, + pub allow_managed_hooks_only: Option>, + pub allow_browser_and_computer_use: Option>, + pub allow_appshots: Option>, + pub allow_remote_control: Option>, + pub computer_use: Option>, + pub browser_use: Option>, + pub in_app_browser: Option>, + pub windows: Option>, + pub feature_requirements: Option>, + pub hooks: Option>, + pub mcp_servers: Option>>, + pub plugins: Option>>, + pub marketplaces: Option>, + pub apps: Option>, + pub rules: Option>, + pub enforce_residency: Option>, + pub network: Option>, + pub application: Option>, + pub permissions: Option>, + pub auto_review: Option>, + pub models: Option>, + pub additional_developer_instructions: Option>, + pub guardian_policy_config: Option>, +} + +impl ConfigRequirementsWithSources { + pub fn merge_unset_fields(&mut self, source: RequirementSource, other: ConfigRequirementsToml) { + // For every field in `other` that is `Some`, if the corresponding field + // in `self` is `None`, copy the value from `other` into `self`. + macro_rules! fill_missing_take { + ($base:expr, $other:expr, $source:expr, { $($field:ident),+ $(,)? }) => { + $( + if $base.$field.is_none() + && let Some(value) = $other.$field.take() + { + $base.$field = Some(Sourced::new(value, $source.clone())); + } + )+ + }; + } + + // Destructure without `..` so adding fields to `ConfigRequirementsToml` + // forces this merge logic to be updated. + let ConfigRequirementsToml { + allowed_login_methods: _, + allowed_chatgpt_workspaces: _, + cli_auth_credentials_store: _, + chatgpt_base_url: _, + sqlite_home: _, + log_dir: _, + model_catalog_json: _, + model_provider: _, + model_providers: _, + check_for_update_on_startup: _, + allow_login_shell: _, + feedback: _, + allowed_approval_policies: _, + allowed_approvals_reviewers: _, + allowed_sandbox_modes: _, + allowed_permission_profiles: _, + default_permissions: _, + remote_sandbox_config: _, + allowed_web_search_modes: _, + allow_managed_hooks_only: _, + allow_browser_and_computer_use: _, + allow_appshots: _, + allow_remote_control: _, + computer_use: _, + browser_use: _, + in_app_browser: _, + windows: _, + feature_requirements: _, + hooks: _, + mcp_servers: _, + plugins: _, + marketplaces: _, + apps: _, + rules: _, + enforce_residency: _, + network: _, + application: _, + permissions: _, + auto_review: _, + models: _, + additional_developer_instructions: _, + guardian_policy_config: _, + } = &other; + + let mut other = other; + if other + .guardian_policy_config + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + other.guardian_policy_config = None; + } + fill_missing_take!( + self, + other, + source, + { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + model_provider, + model_providers, + check_for_update_on_startup, + allow_login_shell, + feedback, + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_permission_profiles, + default_permissions, + allowed_web_search_modes, + allow_managed_hooks_only, + allow_browser_and_computer_use, + allow_appshots, + allow_remote_control, + computer_use, + browser_use, + in_app_browser, + windows, + feature_requirements, + hooks, + mcp_servers, + plugins, + marketplaces, + rules, + enforce_residency, + network, + application, + permissions, + models, + additional_developer_instructions, + guardian_policy_config, + } + ); + + if let Some(incoming_auto_review) = other.auto_review.take() { + if let Some(existing_auto_review) = self.auto_review.as_mut() { + let mut source_contributed = false; + if let Some(incoming_slugs) = incoming_auto_review.required_on_models { + let protected_slugs = existing_auto_review + .value + .required_on_models + .get_or_insert_default(); + for slug in incoming_slugs { + if !protected_slugs.contains(&slug) { + protected_slugs.push(slug); + source_contributed = true; + } + } + } + if existing_auto_review.value.ignore_rules.is_none() + && let Some(ignore_rules) = incoming_auto_review.ignore_rules + { + existing_auto_review.value.ignore_rules = Some(ignore_rules); + source_contributed = true; + } + if source_contributed && existing_auto_review.source != source { + existing_auto_review.source = RequirementSource::composite([ + existing_auto_review.source.clone(), + source.clone(), + ]); + } + } else { + self.auto_review = Some(Sourced::new(incoming_auto_review, source.clone())); + } + } + + if let Some(incoming_apps) = other.apps.take() { + if let Some(existing_apps) = self.apps.as_mut() { + merge_app_requirements_descending(&mut existing_apps.value, incoming_apps); + } else { + self.apps = Some(Sourced::new(incoming_apps, source)); + } + } + } + + pub fn into_toml(self) -> ConfigRequirementsToml { + let ConfigRequirementsWithSources { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + model_provider, + model_providers, + check_for_update_on_startup, + allow_login_shell, + feedback, + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_permission_profiles, + default_permissions, + allowed_web_search_modes, + allow_managed_hooks_only, + allow_browser_and_computer_use, + allow_appshots, + allow_remote_control, + computer_use, + browser_use, + in_app_browser, + windows, + feature_requirements, + hooks, + mcp_servers, + plugins, + marketplaces, + apps, + rules, + enforce_residency, + network, + application, + permissions, + auto_review, + models, + additional_developer_instructions, + guardian_policy_config, + } = self; + ConfigRequirementsToml { + allowed_login_methods: allowed_login_methods.map(|sourced| sourced.value), + allowed_chatgpt_workspaces: allowed_chatgpt_workspaces.map(|sourced| sourced.value), + cli_auth_credentials_store: cli_auth_credentials_store.map(|sourced| sourced.value), + chatgpt_base_url: chatgpt_base_url.map(|sourced| sourced.value), + sqlite_home: sqlite_home.map(|sourced| sourced.value), + log_dir: log_dir.map(|sourced| sourced.value), + model_catalog_json: model_catalog_json.map(|sourced| sourced.value), + model_provider: model_provider.map(|sourced| sourced.value), + model_providers: model_providers.map(|sourced| sourced.value), + check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value), + allow_login_shell: allow_login_shell.map(|sourced| sourced.value), + feedback: feedback.map(|sourced| sourced.value), + allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), + allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), + allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), + allowed_permission_profiles: allowed_permission_profiles.map(|sourced| sourced.value), + default_permissions: default_permissions.map(|sourced| sourced.value), + remote_sandbox_config: None, + allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), + allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), + allow_browser_and_computer_use: allow_browser_and_computer_use + .map(|sourced| sourced.value), + allow_appshots: allow_appshots.map(|sourced| sourced.value), + allow_remote_control: allow_remote_control.map(|sourced| sourced.value), + computer_use: computer_use.map(|sourced| sourced.value), + browser_use: browser_use.map(|sourced| sourced.value), + in_app_browser: in_app_browser.map(|sourced| sourced.value), + windows: windows.map(|sourced| sourced.value), + feature_requirements: feature_requirements.map(|sourced| sourced.value), + hooks: hooks.map(|sourced| sourced.value), + mcp_servers: mcp_servers.map(|sourced| sourced.value), + plugins: plugins.map(|sourced| sourced.value), + marketplaces: marketplaces.map(|sourced| sourced.value), + apps: apps.map(|sourced| sourced.value), + rules: rules.map(|sourced| sourced.value), + enforce_residency: enforce_residency.map(|sourced| sourced.value), + network: network.map(|sourced| sourced.value), + application: application.map(|sourced| sourced.value), + permissions: permissions.map(|sourced| sourced.value), + auto_review: auto_review.map(|sourced| sourced.value), + models: models.map(|sourced| sourced.value), + additional_developer_instructions: additional_developer_instructions + .map(|sourced| sourced.value), + guardian_policy_config: guardian_policy_config.map(|sourced| sourced.value), + } + } +} + +fn normalize_hostname(hostname: &str) -> Option { + let hostname = hostname.trim().trim_end_matches('.'); + (!hostname.is_empty()).then(|| hostname.to_ascii_lowercase()) +} + +fn hostname_matches_any_pattern(hostname: &str, patterns: &[String]) -> bool { + patterns.iter().any(|pattern| { + normalize_hostname(pattern) + .map(|pattern| WildMatchPattern::<'*', '?'>::new_case_insensitive(&pattern)) + .is_some_and(|pattern| pattern.matches(hostname)) + }) +} + +/// Currently, `external-sandbox` is not supported in config.toml, but it is +/// supported through programmatic use. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] +pub enum SandboxModeRequirement { + #[serde(rename = "read-only")] + ReadOnly, + + #[serde(rename = "workspace-write")] + WorkspaceWrite, + + #[serde(rename = "danger-full-access")] + DangerFullAccess, + + #[serde(rename = "external-sandbox")] + ExternalSandbox, +} + +impl From for SandboxModeRequirement { + fn from(mode: SandboxMode) -> Self { + match mode { + SandboxMode::ReadOnly => SandboxModeRequirement::ReadOnly, + SandboxMode::WorkspaceWrite => SandboxModeRequirement::WorkspaceWrite, + SandboxMode::DangerFullAccess => SandboxModeRequirement::DangerFullAccess, + } + } +} + +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ResidencyRequirement { + Us, +} + +impl ConfigRequirementsToml { + pub fn apply_remote_sandbox_config(&mut self, hostname: Option<&str>) { + let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else { + return; + }; + let Some(hostname) = hostname.and_then(normalize_hostname) else { + return; + }; + let Some(matched_config) = remote_sandbox_config + .iter() + .find(|config| hostname_matches_any_pattern(&hostname, &config.hostname_patterns)) + else { + return; + }; + self.allowed_sandbox_modes = Some(matched_config.allowed_sandbox_modes.clone()); + } + + pub fn is_empty(&self) -> bool { + self.allowed_login_methods.is_none() + && self.allowed_chatgpt_workspaces.is_none() + && self.cli_auth_credentials_store.is_none() + && self.chatgpt_base_url.is_none() + && self.sqlite_home.is_none() + && self.log_dir.is_none() + && self.model_catalog_json.is_none() + && self.model_provider.is_none() + && self.model_providers.as_ref().is_none_or(HashMap::is_empty) + && self.check_for_update_on_startup.is_none() + && self.allow_login_shell.is_none() + && self + .feedback + .as_ref() + .is_none_or(|feedback| feedback == &FeedbackConfigToml::default()) + && self.allowed_approval_policies.is_none() + && self.allowed_approvals_reviewers.is_none() + && self.allowed_sandbox_modes.is_none() + && self.allowed_permission_profiles.is_none() + && self.default_permissions.is_none() + && self.remote_sandbox_config.is_none() + && self.allowed_web_search_modes.is_none() + && self.allow_managed_hooks_only.is_none() + && self.allow_browser_and_computer_use.is_none() + && self.allow_appshots.is_none() + && self.allow_remote_control.is_none() + && self + .computer_use + .as_ref() + .is_none_or(ComputerUseRequirementsToml::is_empty) + && self + .browser_use + .as_ref() + .is_none_or(BrowserUseRequirementsToml::is_empty) + && self + .in_app_browser + .as_ref() + .is_none_or(|requirements| requirements == &InAppBrowserRequirementsToml::default()) + && self + .windows + .as_ref() + .is_none_or(WindowsRequirementsToml::is_empty) + && self + .feature_requirements + .as_ref() + .is_none_or(FeatureRequirementsToml::is_empty) + && self + .hooks + .as_ref() + .is_none_or(ManagedHooksRequirementsToml::is_empty) + && self.mcp_servers.is_none() + && self + .plugins + .as_ref() + .is_none_or(|plugins| plugins.values().all(PluginRequirementsToml::is_empty)) + && self + .marketplaces + .as_ref() + .is_none_or(MarketplaceRequirementsToml::is_empty) + && self + .apps + .as_ref() + .is_none_or(AppsRequirementsToml::is_empty) + && self.rules.is_none() + && self.enforce_residency.is_none() + && self.network.is_none() + && self + .application + .as_ref() + .is_none_or(|application| application.network.is_none()) + && self.permissions.is_none() + && self.auto_review.as_ref().is_none_or(|auto_review| { + auto_review.ignore_rules.as_ref().is_none_or(Vec::is_empty) + && auto_review + .required_on_models + .as_ref() + .is_none_or(Vec::is_empty) + }) + && self + .models + .as_ref() + .is_none_or(ModelsRequirementsToml::is_empty) + && self.additional_developer_instructions.is_none() + && self + .guardian_policy_config + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + } + + /// Applies the requirements whose values replace config values. + /// + /// This projection keeps config/read aligned with the final runtime config. + pub fn apply_exact_to_config(&self, config: &mut ConfigToml) { + macro_rules! apply_exact { + ($field:ident) => { + if let Some(value) = self.$field.as_ref() { + config.$field = Some(value.clone()); + } + }; + } + + apply_exact!(cli_auth_credentials_store); + apply_exact!(chatgpt_base_url); + apply_exact!(sqlite_home); + apply_exact!(log_dir); + apply_exact!(model_catalog_json); + apply_exact!(model_provider); + if let Some(providers) = &self.model_providers { + config.model_providers.extend(providers.clone()); + } + apply_exact!(check_for_update_on_startup); + apply_exact!(allow_login_shell); + + if self + .allowed_approvals_reviewers + .as_ref() + .is_some_and(|reviewers| !reviewers.contains(&ApprovalsReviewer::User)) + { + config.features.get_or_insert_default().guardianv2 = Some(FeatureToml::Enabled(false)); + } + + if let Some(enabled) = self.feedback.as_ref().and_then(|feedback| feedback.enabled) { + config.feedback.get_or_insert_default().enabled = Some(enabled); + } + if let Some(sandbox_private_desktop) = self + .windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop) + { + config + .windows + .get_or_insert_default() + .sandbox_private_desktop = Some(sandbox_private_desktop); + } + } + + /// Returns the exact managed field affected by editing `segments`. + pub fn exact_requirement_for_config_path(&self, segments: &[String]) -> Option<&'static str> { + if self.model_providers.as_ref().is_some_and(|providers| { + providers + .keys() + .any(|id| config_paths_overlap(segments, &["model_providers", id])) + }) { + return Some("model_providers"); + } + let managed_fields: [(bool, &[&str], &'static str); 10] = [ + ( + self.model_provider.is_some(), + &["model_provider"], + "model_provider", + ), + (self.sqlite_home.is_some(), &["sqlite_home"], "sqlite_home"), + (self.log_dir.is_some(), &["log_dir"], "log_dir"), + ( + self.model_catalog_json.is_some(), + &["model_catalog_json"], + "model_catalog_json", + ), + ( + self.check_for_update_on_startup.is_some(), + &["check_for_update_on_startup"], + "check_for_update_on_startup", + ), + ( + self.allow_login_shell.is_some(), + &["allow_login_shell"], + "allow_login_shell", + ), + ( + self.feedback + .as_ref() + .and_then(|feedback| feedback.enabled) + .is_some(), + &["feedback", "enabled"], + "feedback.enabled", + ), + ( + self.windows + .as_ref() + .and_then(|windows| windows.sandbox_private_desktop) + .is_some(), + &["windows", "sandbox_private_desktop"], + "windows.sandbox_private_desktop", + ), + ( + self.cli_auth_credentials_store.is_some(), + &["cli_auth_credentials_store"], + "cli_auth_credentials_store", + ), + ( + self.chatgpt_base_url.is_some(), + &["chatgpt_base_url"], + "chatgpt_base_url", + ), + ]; + + managed_fields + .into_iter() + .find_map(|(is_managed, managed_path, field)| { + (is_managed && config_paths_overlap(segments, managed_path)).then_some(field) + }) + } +} + +fn config_paths_overlap(segments: &[String], managed_path: &[&str]) -> bool { + segments + .iter() + .zip(managed_path) + .all(|(segment, managed_segment)| segment == managed_segment) +} + +fn validate_mcp_server_requirements( + requirements: &BTreeMap, + source: &RequirementSource, + plugin_name: Option<&str>, +) -> Result<(), ConstraintError> { + for (server_name, requirement) in requirements { + validate_mcp_server_requirement(requirement).map_err(|reason| { + ConstraintError::McpServerRequirementParse { + server_name: plugin_name + .map(|plugin_name| format!("{plugin_name}/{server_name}")) + .unwrap_or_else(|| server_name.clone()), + requirement_source: source.clone(), + reason, + } + })?; + } + Ok(()) +} + +impl TryFrom for ConfigRequirements { + type Error = ConstraintError; + + fn try_from(toml: ConfigRequirementsWithSources) -> Result { + // Profile catalog selection remains on ConfigRequirementsToml for + // config loading and requirements API projection. Managed new-thread + // defaults also remain there because they are initialization values; + // model-specific auto-review requirements are runtime constraints. + let ConfigRequirementsWithSources { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + model_provider, + model_providers, + check_for_update_on_startup, + allow_login_shell, + feedback, + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_permission_profiles: _, + default_permissions: _, + allowed_web_search_modes, + allow_managed_hooks_only, + allow_browser_and_computer_use: _, + allow_appshots, + allow_remote_control, + computer_use, + browser_use: _, + in_app_browser: _, + windows, + feature_requirements, + hooks, + mcp_servers, + plugins, + marketplaces, + apps: _apps, + rules, + enforce_residency, + network, + application, + permissions, + auto_review, + models: _, + additional_developer_instructions, + guardian_policy_config, + } = toml; + + let auto_review_required_models = auto_review + .and_then(|auto_review| { + auto_review + .value + .required_on_models + .map(|slugs| Sourced::new(slugs, auto_review.source)) + }) + .filter(|models| !models.value.is_empty()) + .map(|models| { + let Sourced { value, source } = models; + let mut protected_models = BTreeSet::new(); + for slug in value { + if slug.trim().is_empty() || slug.trim() != slug || slug.contains('/') { + return Err(ConstraintError::InvalidValue { + field_name: "auto_review.required_on_models", + candidate: format!("{slug:?}"), + allowed: "non-empty model slugs without surrounding whitespace or provider namespaces" + .to_string(), + requirement_source: source, + }); + } + protected_models.insert(slug); + } + Ok(Sourced::new(protected_models, source)) + }) + .transpose()?; + + if let Some(requirements) = &mcp_servers { + validate_mcp_server_requirements( + &requirements.value, + &requirements.source, + /*plugin_name*/ None, + )?; + } + if let Some(plugin_requirements) = &plugins { + for (plugin_name, plugin) in &plugin_requirements.value { + if let Some(requirements) = &plugin.mcp_servers { + validate_mcp_server_requirements( + requirements, + &plugin_requirements.source, + Some(plugin_name), + )?; + } + } + } + + let approval_policy = match allowed_approval_policies { + Some(Sourced { + value: policies, + source: requirement_source, + }) => { + let Some(initial_value) = policies.first().copied() else { + return Err(ConstraintError::empty_field("allowed_approval_policies")); + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + if policies.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: format!("{candidate:?}"), + allowed: format!("{policies:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + }; + + let approvals_reviewer = match allowed_approvals_reviewers { + Some(Sourced { + value: reviewers, + source: requirement_source, + }) => { + let Some(initial_value) = reviewers.first().copied() else { + return Err(ConstraintError::empty_field("allowed_approvals_reviewers")); + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + if reviewers.contains(candidate) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "approvals_reviewer", + candidate: format!("{candidate:?}"), + allowed: format!("{reviewers:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any_from_default(), + /*source*/ None, + ), + }; + + let default_permission_profile = PermissionProfile::read_only(); + let permission_profile = match allowed_sandbox_modes { + Some(Sourced { + value: modes, + source: requirement_source, + }) => { + if !modes.contains(&SandboxModeRequirement::ReadOnly) { + return Err(ConstraintError::InvalidValue { + field_name: "allowed_sandbox_modes", + candidate: format!("{modes:?}"), + allowed: "must include 'read-only' to allow any PermissionProfile" + .to_string(), + requirement_source, + }); + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(default_permission_profile, move |candidate| { + let mode = sandbox_mode_requirement_for_permission_profile(candidate); + if modes.contains(&mode) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: format!("{modes:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(default_permission_profile), + /*source*/ None, + ), + }; + let (windows_sandbox_mode, windows_sandbox_private_desktop) = match windows { + Some(Sourced { + value: + WindowsRequirementsToml { + allowed_sandbox_implementations, + sandbox_private_desktop, + }, + source: requirement_source, + }) => { + let sandbox_private_desktop = sandbox_private_desktop + .map(|value| Sourced::new(value, requirement_source.clone())); + let sandbox_mode = match allowed_sandbox_implementations { + Some(implementations) => { + if implementations.is_empty() { + return Err(ConstraintError::empty_field( + "windows.allowed_sandbox_implementations", + )); + } + // Prefer elevated when both Windows sandbox implementations are allowed. + let initial_value = + if implementations.contains(&WindowsSandboxModeToml::Elevated) { + WindowsSandboxModeToml::Elevated + } else { + WindowsSandboxModeToml::Unelevated + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new( + Some(initial_value), + move |candidate| match candidate { + Some(candidate) if implementations.contains(candidate) => Ok(()), + _ => Err(ConstraintError::InvalidValue { + field_name: "windows.sandbox", + candidate: format!("{candidate:?}"), + allowed: format!("{implementations:?}"), + requirement_source: requirement_source_for_error.clone(), + }), + }, + )?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + }; + (sandbox_mode, sandbox_private_desktop) + } + None => ( + ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + None, + ), + }; + let exec_policy = match rules { + Some(Sourced { value, source }) => { + let policy = value.to_requirements_policy().map_err(|err| { + ConstraintError::ExecPolicyParse { + requirement_source: source.clone(), + reason: err.to_string(), + } + })?; + Some(Sourced::new(policy, source)) + } + None => None, + }; + let web_search_mode = match allowed_web_search_modes { + Some(Sourced { + value: modes, + source: requirement_source, + }) => { + let mut accepted = modes.into_iter().collect::>(); + accepted.insert(WebSearchModeRequirement::Disabled); + let allowed_for_error = format!( + "{:?}", + accepted + .iter() + .copied() + .map(WebSearchMode::from) + .collect::>() + ); + + let initial_value = if accepted.contains(&WebSearchModeRequirement::Cached) { + WebSearchMode::Cached + } else if accepted.contains(&WebSearchModeRequirement::Indexed) { + WebSearchMode::Indexed + } else if accepted.contains(&WebSearchModeRequirement::Live) { + WebSearchMode::Live + } else { + WebSearchMode::Disabled + }; + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(initial_value, move |candidate| { + if accepted.contains(&(*candidate).into()) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{candidate:?}"), + allowed: allowed_for_error.clone(), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(WebSearchMode::Cached), + /*source*/ None, + ), + }; + let feature_requirements = + feature_requirements.filter(|requirements| !requirements.value.is_empty()); + let managed_hooks = hooks + .filter(|managed_hooks| managed_hooks.value.handler_count() > 0) + .map(|sourced_hooks| { + let Sourced { + value, + source: requirement_source, + } = sourced_hooks; + let allowed = value; + let allowed_for_error = format!("{allowed:?}"); + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(allowed.clone(), move |candidate| { + if candidate == &allowed { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "hooks", + candidate: format!("{candidate:?}"), + allowed: allowed_for_error.clone(), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + Ok(ConstrainedWithSource::new( + constrained, + Some(requirement_source), + )) + }) + .transpose()?; + + let enforce_residency = match enforce_residency { + Some(Sourced { + value: residency, + source: requirement_source, + }) => { + let required = Some(residency); + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new(required, move |candidate| { + if candidate == &required { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "enforce_residency", + candidate: format!("{candidate:?}"), + allowed: format!("{required:?}"), + requirement_source: requirement_source_for_error.clone(), + }) + } + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + }; + let network = network.map(|sourced_network| { + let Sourced { value, source } = sourced_network; + Sourced::new(NetworkConstraints::from(value), source) + }); + let filesystem = permissions.map(|sourced_permissions| { + let Sourced { value, source } = sourced_permissions; + Sourced::new(FilesystemConstraints::from(value), source) + }); + let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); + Ok(ConfigRequirements { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + model_provider, + model_providers, + check_for_update_on_startup, + allow_login_shell, + feedback, + approval_policy, + approvals_reviewer, + auto_review_required_models, + permission_profile, + windows_sandbox_mode, + windows_sandbox_private_desktop, + web_search_mode, + allow_managed_hooks_only, + allow_appshots, + allow_remote_control, + computer_use, + feature_requirements, + managed_hooks, + mcp_servers, + plugins, + marketplaces, + exec_policy, + enforce_residency, + network, + application, + filesystem, + additional_developer_instructions, + guardian_policy_config_source, + }) + } +} + +pub fn sandbox_mode_requirement_for_permission_profile( + permission_profile: &PermissionProfile, +) -> SandboxModeRequirement { + match permission_profile { + PermissionProfile::Disabled => SandboxModeRequirement::DangerFullAccess, + PermissionProfile::External { .. } => SandboxModeRequirement::ExternalSandbox, + PermissionProfile::Managed { .. } => { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + if file_system_policy.has_full_disk_write_access() { + SandboxModeRequirement::DangerFullAccess + } else if file_system_policy + .entries + .iter() + .any(|entry| entry.access.can_write()) + { + SandboxModeRequirement::WorkspaceWrite + } else { + SandboxModeRequirement::ReadOnly + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AllowDenyRequirementToml; + use crate::BrowserUseAccessApprovalLifetimeToml; + use crate::BrowserUseOriginPolicyToml; + use crate::ComputerUseMacosRequirementsToml; + use crate::ComputerUseWindowsExeRequirementToml; + use crate::ComputerUseWindowsRequirementsToml; + use crate::HookEventsToml; + use crate::McpServerCommandMatcher; + use crate::McpServerIdentity; + use crate::McpServerValueMatcher; + use anyhow::Result; + use codex_execpolicy::Decision; + use codex_execpolicy::Evaluation; + use codex_execpolicy::RuleMatch; + use codex_protocol::permissions::NetworkSandboxPolicy; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_absolute_path::AbsolutePathBufGuard; + use pretty_assertions::assert_eq; + use toml::from_str; + + fn tokens(cmd: &[&str]) -> Vec { + cmd.iter().map(std::string::ToString::to_string).collect() + } + + fn system_requirements_toml_file_for_test() -> Result { + Ok(AbsolutePathBuf::try_from( + std::env::temp_dir().join("requirements.toml"), + )?) + } + + #[test] + fn exact_requirement_for_config_path_matches_overlapping_paths() { + let managed_path = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed")) + .expect("managed path should be absolute"); + let requirements = ConfigRequirementsToml { + cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Ephemeral), + chatgpt_base_url: Some("https://managed.example/backend-api/".to_string()), + sqlite_home: Some(managed_path.clone()), + log_dir: Some(managed_path.clone()), + model_catalog_json: Some(managed_path), + check_for_update_on_startup: Some(false), + allow_login_shell: Some(false), + feedback: Some(FeedbackConfigToml { + enabled: Some(false), + }), + windows: Some(WindowsRequirementsToml { + sandbox_private_desktop: Some(false), + ..Default::default() + }), + ..Default::default() + }; + let cases: &[(&[&str], Option<&str>)] = &[ + ( + &["cli_auth_credentials_store"], + Some("cli_auth_credentials_store"), + ), + (&["chatgpt_base_url"], Some("chatgpt_base_url")), + (&["sqlite_home"], Some("sqlite_home")), + (&["log_dir"], Some("log_dir")), + (&["model_catalog_json"], Some("model_catalog_json")), + ( + &["check_for_update_on_startup"], + Some("check_for_update_on_startup"), + ), + (&["allow_login_shell"], Some("allow_login_shell")), + (&["feedback", "enabled"], Some("feedback.enabled")), + ( + &["windows", "sandbox_private_desktop"], + Some("windows.sandbox_private_desktop"), + ), + (&[], Some("sqlite_home")), + (&["feedback"], Some("feedback.enabled")), + ( + &["windows", "sandbox_private_desktop", "value"], + Some("windows.sandbox_private_desktop"), + ), + (&["feedback", "other"], None), + (&["windows", "sandbox"], None), + ]; + + for (segments, expected) in cases { + let segments = segments.iter().map(ToString::to_string).collect::>(); + assert_eq!( + requirements.exact_requirement_for_config_path(&segments), + *expected, + "segments: {segments:?}" + ); + } + } + + #[test] + fn composite_requirement_source_flattens_and_deduplicates_sources() { + let mdm_source = RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }; + let legacy_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + + assert_eq!( + RequirementSource::composite([ + mdm_source.clone(), + RequirementSource::composite([legacy_source.clone(), mdm_source.clone()]), + ]), + RequirementSource::Composite { + sources: vec![mdm_source, legacy_source], + } + ); + } + + fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { + let ConfigRequirementsToml { + allowed_login_methods, + allowed_chatgpt_workspaces, + cli_auth_credentials_store, + chatgpt_base_url, + sqlite_home, + log_dir, + model_catalog_json, + model_provider, + model_providers, + check_for_update_on_startup, + allow_login_shell, + feedback, + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_permission_profiles, + default_permissions, + remote_sandbox_config: _, + allowed_web_search_modes, + allow_managed_hooks_only, + allow_browser_and_computer_use, + allow_appshots, + allow_remote_control, + computer_use, + browser_use, + in_app_browser, + windows, + feature_requirements, + hooks, + mcp_servers, + plugins, + marketplaces, + apps, + rules, + enforce_residency, + network, + application, + permissions, + auto_review, + models, + additional_developer_instructions, + guardian_policy_config, + } = toml; + ConfigRequirementsWithSources { + allowed_login_methods: allowed_login_methods + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_chatgpt_workspaces: allowed_chatgpt_workspaces + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + cli_auth_credentials_store: cli_auth_credentials_store + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + chatgpt_base_url: chatgpt_base_url + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)), + log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)), + model_catalog_json: model_catalog_json + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + model_provider: model_provider + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + model_providers: model_providers + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + check_for_update_on_startup: check_for_update_on_startup + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_login_shell: allow_login_shell + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + feedback: feedback.map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_approval_policies: allowed_approval_policies + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_approvals_reviewers: allowed_approvals_reviewers + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_sandbox_modes: allowed_sandbox_modes + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_permission_profiles: allowed_permission_profiles + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + default_permissions: default_permissions + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_web_search_modes: allowed_web_search_modes + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_managed_hooks_only: allow_managed_hooks_only + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_browser_and_computer_use: allow_browser_and_computer_use + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_appshots: allow_appshots + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_remote_control: allow_remote_control + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), + browser_use: browser_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), + in_app_browser: in_app_browser + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + windows: windows.map(|value| Sourced::new(value, RequirementSource::Unknown)), + feature_requirements: feature_requirements + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), + mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)), + plugins: plugins.map(|value| Sourced::new(value, RequirementSource::Unknown)), + marketplaces: marketplaces.map(|value| Sourced::new(value, RequirementSource::Unknown)), + apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)), + rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)), + enforce_residency: enforce_residency + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + network: network.map(|value| Sourced::new(value, RequirementSource::Unknown)), + application: application.map(|value| Sourced::new(value, RequirementSource::Unknown)), + permissions: permissions.map(|value| Sourced::new(value, RequirementSource::Unknown)), + auto_review: auto_review.map(|value| Sourced::new(value, RequirementSource::Unknown)), + models: models.map(|value| Sourced::new(value, RequirementSource::Unknown)), + additional_developer_instructions: additional_developer_instructions + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + guardian_policy_config: guardian_policy_config + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + } + } + + #[test] + fn deserialize_allow_managed_hooks_only() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_managed_hooks_only = true + "#, + )?; + + assert_eq!(requirements.allow_managed_hooks_only, Some(true)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn allow_managed_hooks_only_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_managed_hooks_only = false + "#, + )?; + + assert_eq!(requirements.allow_managed_hooks_only, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn deserialize_managed_permission_profiles() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + default_permissions = "managed-standard" + + [allowed_permission_profiles] + managed-standard = true + managed-build = true + + [permissions.managed-standard] + extends = ":workspace" + + [permissions.managed-build] + extends = "managed-standard" + "#, + )?; + + assert_eq!( + requirements.allowed_permission_profiles, + Some(BTreeMap::from([ + ("managed-build".to_string(), true), + ("managed-standard".to_string(), true), + ])) + ); + assert_eq!( + requirements.default_permissions, + Some("managed-standard".to_string()) + ); + let permissions = requirements + .permissions + .as_ref() + .expect("managed permission profiles"); + assert!(permissions.profiles.contains_key("managed-standard")); + assert!( + permissions + .profiles + .get("managed-build") + .and_then(|profile| profile.extends.as_deref()) + .is_some() + ); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn deserialize_allow_appshots() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = true + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(true)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn filesystem_requirements_table_cannot_define_a_permission_profile() { + let err = from_str::( + r#" + [permissions.filesystem] + extends = ":workspace" + "#, + ) + .expect_err("filesystem requirements cannot define a permission profile"); + + assert!( + err.to_string().contains( + "`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile" + ), + "unexpected error: {err:#}" + ); + } + + #[test] + fn allow_appshots_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = false + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn allow_remote_control_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_remote_control = false + "#, + )?; + + assert_eq!(requirements.allow_remote_control, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn deserialize_browser_and_computer_use_requirements() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_browser_and_computer_use = false + + [browser_use] + allow_history_access = false + disable_auto_review = true + allow_global_persistent_approval = false + + [browser_use.default_origin_policy] + access = "deny" + downloads = "allow" + uploads = "deny" + full_cdp_access = "allow" + auto_review = "deny" + persistent_approval = false + access_approval_lifetime = "turn" + + [browser_use.origins."https://example.com"] + access = "allow" + downloads = "deny" + uploads = "allow" + full_cdp_access = "deny" + auto_review = "deny" + persistent_approval = true + access_approval_lifetime = "thread" + + [computer_use] + allow_locked_computer_use = false + allow_persistent_approval = false + default_app_access = "deny" + + [computer_use.macos.bundle_ids] + "com.apple.Safari" = "allow" + + [computer_use.windows.aumids] + "Microsoft.Paint_8wekyb3d8bbwe!App" = "allow" + + [[computer_use.windows.exes]] + publisher_name = "CN=Google LLC, O=Google LLC, L=Mountain View, S=California, C=US" + product_name = "Google Chrome" + binary_name = "chrome.exe" + access = "deny" + "#, + )?; + + assert_eq!(requirements.allow_browser_and_computer_use, Some(false)); + assert_eq!( + requirements.browser_use, + Some(BrowserUseRequirementsToml { + allow_webmcp: None, + allow_history_access: Some(false), + disable_auto_review: Some(true), + allow_global_persistent_approval: Some(false), + default_origin_policy: Some(BrowserUseOriginPolicyToml { + access: Some(AllowDenyRequirementToml::Deny), + downloads: Some(AllowDenyRequirementToml::Allow), + uploads: Some(AllowDenyRequirementToml::Deny), + full_cdp_access: Some(AllowDenyRequirementToml::Allow), + auto_review: Some(AllowDenyRequirementToml::Deny), + persistent_approval: Some(false), + access_approval_lifetime: Some(BrowserUseAccessApprovalLifetimeToml::Turn), + }), + origins: Some(BTreeMap::from([( + "https://example.com".to_string(), + BrowserUseOriginPolicyToml { + access: Some(AllowDenyRequirementToml::Allow), + downloads: Some(AllowDenyRequirementToml::Deny), + uploads: Some(AllowDenyRequirementToml::Allow), + full_cdp_access: Some(AllowDenyRequirementToml::Deny), + auto_review: Some(AllowDenyRequirementToml::Deny), + persistent_approval: Some(true), + access_approval_lifetime: Some( + BrowserUseAccessApprovalLifetimeToml::Thread, + ), + }, + )])), + }) + ); + assert_eq!( + requirements.computer_use, + Some(ComputerUseRequirementsToml { + allow_locked_computer_use: Some(false), + allow_persistent_approval: Some(false), + default_app_access: Some(AllowDenyRequirementToml::Deny), + macos: Some(ComputerUseMacosRequirementsToml { + bundle_ids: Some(BTreeMap::from([( + "com.apple.Safari".to_string(), + AllowDenyRequirementToml::Allow, + )])), + }), + windows: Some(ComputerUseWindowsRequirementsToml { + aumids: Some(BTreeMap::from([( + "Microsoft.Paint_8wekyb3d8bbwe!App".to_string(), + AllowDenyRequirementToml::Allow, + )])), + exes: Some(vec![ComputerUseWindowsExeRequirementToml { + publisher_name: + "CN=Google LLC, O=Google LLC, L=Mountain View, S=California, C=US" + .to_string(), + product_name: "Google Chrome".to_string(), + binary_name: Some("chrome.exe".to_string()), + access: AllowDenyRequirementToml::Deny, + }]), + }), + }) + ); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn browser_and_computer_use_leaf_requirements_are_not_empty() -> Result<()> { + for (name, requirements_toml) in [ + ( + "browser history", + "[browser_use]\nallow_history_access = false", + ), + ( + "browser auto-review", + "[browser_use]\ndisable_auto_review = true", + ), + ( + "global persistent approval", + "[browser_use]\nallow_global_persistent_approval = false", + ), + ( + "default origin access", + "[browser_use.default_origin_policy]\naccess = \"deny\"", + ), + ( + "default origin downloads", + "[browser_use.default_origin_policy]\ndownloads = \"deny\"", + ), + ( + "default origin uploads", + "[browser_use.default_origin_policy]\nuploads = \"deny\"", + ), + ( + "default origin full CDP access", + "[browser_use.default_origin_policy]\nfull_cdp_access = \"deny\"", + ), + ( + "default origin auto-review", + "[browser_use.default_origin_policy]\nauto_review = \"deny\"", + ), + ( + "default origin persistent approval", + "[browser_use.default_origin_policy]\npersistent_approval = false", + ), + ( + "default origin access approval lifetime", + "[browser_use.default_origin_policy]\naccess_approval_lifetime = \"turn\"", + ), + ( + "origin access", + "[browser_use.origins.\"https://example.com\"]\naccess = \"deny\"", + ), + ( + "origin downloads", + "[browser_use.origins.\"https://example.com\"]\ndownloads = \"deny\"", + ), + ( + "origin uploads", + "[browser_use.origins.\"https://example.com\"]\nuploads = \"deny\"", + ), + ( + "origin full CDP access", + "[browser_use.origins.\"https://example.com\"]\nfull_cdp_access = \"deny\"", + ), + ( + "origin auto-review", + "[browser_use.origins.\"https://example.com\"]\nauto_review = \"deny\"", + ), + ( + "origin persistent approval", + "[browser_use.origins.\"https://example.com\"]\npersistent_approval = false", + ), + ( + "origin access approval lifetime", + "[browser_use.origins.\"https://example.com\"]\naccess_approval_lifetime = \"turn\"", + ), + ( + "computer persistent approval", + "[computer_use]\nallow_persistent_approval = false", + ), + ( + "default app access", + "[computer_use]\ndefault_app_access = \"deny\"", + ), + ( + "macOS bundle identifier", + "[computer_use.macos.bundle_ids]\n\"com.example.App\" = \"deny\"", + ), + ( + "Windows AUMID", + "[computer_use.windows.aumids]\n\"Example.App_123!Main\" = \"deny\"", + ), + ( + "Windows executable", + "[[computer_use.windows.exes]]\npublisher_name = \"CN=Example Corp\"\nproduct_name = \"Example App\"\naccess = \"deny\"", + ), + ] { + let requirements: ConfigRequirementsToml = from_str(requirements_toml)?; + assert!(!requirements.is_empty(), "{name} requirement was dropped"); + } + + Ok(()) + } + + #[test] + fn deserialize_new_thread_model_defaults() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + [models.new_thread] + model = "managed-model" + model_reasoning_effort = "medium" + service_tier = "fast" + "#, + )?; + + assert_eq!( + requirements.models, + Some(ModelsRequirementsToml { + new_thread: Some(NewThreadModelDefaultsToml { + model: Some("managed-model".to_string()), + model_reasoning_effort: Some(ReasoningEffort::Medium), + service_tier: Some("fast".to_string()), + }), + }) + ); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn auto_review_required_for_model_matches_exact_provider_aliases() { + let requirements = ConfigRequirements { + auto_review_required_models: Some(Sourced::new( + BTreeSet::from(["protected-model".to_string()]), + RequirementSource::Unknown, + )), + ..Default::default() + }; + + for (model, protected) in [ + ("protected-model", true), + ("protected-model-preview", false), + ("openai-codex/protected-model-preview", false), + ("provider_1/protected-model", true), + ("protected-modelish", false), + ("/protected-model", false), + ("bad.provider/protected-model", false), + ("provider/nested/protected-model", false), + ] { + assert_eq!( + requirements.auto_review_required_for_model(model), + protected, + "{model}" + ); + } + } + + #[test] + fn merge_unset_fields_copies_every_field_and_sets_sources() { + let mut target = ConfigRequirementsWithSources::default(); + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; + + let allowed_approval_policies = vec![AskForApproval::UnlessTrusted, AskForApproval::Never]; + let allowed_approvals_reviewers = + vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User]; + let allowed_sandbox_modes = vec![ + SandboxModeRequirement::WorkspaceWrite, + SandboxModeRequirement::DangerFullAccess, + ]; + let allowed_web_search_modes = vec![ + WebSearchModeRequirement::Cached, + WebSearchModeRequirement::Live, + ]; + let feature_requirements = FeatureRequirementsToml { + entries: BTreeMap::from([("personality".to_string(), true)]), + }; + let browser_use = BrowserUseRequirementsToml { + allow_webmcp: None, + allow_history_access: Some(false), + disable_auto_review: Some(true), + allow_global_persistent_approval: None, + default_origin_policy: None, + origins: None, + }; + let computer_use = ComputerUseRequirementsToml { + allow_locked_computer_use: Some(false), + allow_persistent_approval: Some(false), + default_app_access: None, + macos: None, + windows: None, + }; + let auto_review = AutoReviewRequirementsToml { + required_on_models: Some(vec!["managed-model".to_string()]), + ignore_rules: None, + }; + let models = ModelsRequirementsToml { + new_thread: Some(NewThreadModelDefaultsToml { + model: Some("managed-model".to_string()), + model_reasoning_effort: Some(ReasoningEffort::Medium), + service_tier: Some("fast".to_string()), + }), + }; + let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state")) + .expect("managed sqlite home should be absolute"); + let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs")) + .expect("managed log dir should be absolute"); + let model_catalog_json = + AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json")) + .expect("managed model catalog path should be absolute"); + let feedback = FeedbackConfigToml { + enabled: Some(false), + }; + let windows = WindowsRequirementsToml { + allowed_sandbox_implementations: None, + sandbox_private_desktop: Some(true), + }; + let enforce_residency = ResidencyRequirement::Us; + let enforce_source = source.clone(); + let additional_developer_instructions = "Follow the company policy.".to_string(); + let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); + + // Intentionally constructed without `..Default::default()` so adding a new field to + // `ConfigRequirementsToml` forces this test to be updated. + let other = ConfigRequirementsToml { + allowed_login_methods: Some(vec![ForcedLoginMethod::Chatgpt]), + allowed_chatgpt_workspaces: Some(vec!["managed-workspace".to_string()]), + cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring), + chatgpt_base_url: Some("https://managed.example/backend-api/".to_string()), + sqlite_home: Some(sqlite_home.clone()), + log_dir: Some(log_dir.clone()), + model_catalog_json: Some(model_catalog_json.clone()), + model_provider: Some("gateway".to_string()), + model_providers: Some(HashMap::new()), + check_for_update_on_startup: Some(false), + allow_login_shell: Some(false), + feedback: Some(feedback.clone()), + allowed_approval_policies: Some(allowed_approval_policies.clone()), + allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), + allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), + allowed_permission_profiles: Some(BTreeMap::from([("managed".to_string(), true)])), + default_permissions: Some("managed".to_string()), + remote_sandbox_config: None, + allowed_web_search_modes: Some(allowed_web_search_modes.clone()), + allow_managed_hooks_only: Some(true), + allow_browser_and_computer_use: Some(false), + allow_appshots: Some(false), + allow_remote_control: Some(false), + computer_use: Some(computer_use.clone()), + browser_use: Some(browser_use.clone()), + in_app_browser: None, + windows: Some(windows.clone()), + feature_requirements: Some(feature_requirements.clone()), + hooks: None, + mcp_servers: None, + plugins: None, + marketplaces: None, + apps: None, + rules: None, + enforce_residency: Some(enforce_residency), + network: None, + application: None, + permissions: None, + auto_review: Some(auto_review.clone()), + models: Some(models.clone()), + additional_developer_instructions: Some(additional_developer_instructions.clone()), + guardian_policy_config: Some(guardian_policy_config.clone()), + }; + + target.merge_unset_fields(source.clone(), other); + + assert_eq!( + target, + ConfigRequirementsWithSources { + allowed_login_methods: Some(Sourced::new( + vec![ForcedLoginMethod::Chatgpt], + source.clone(), + )), + allowed_chatgpt_workspaces: Some(Sourced::new( + vec!["managed-workspace".to_string()], + source.clone(), + )), + cli_auth_credentials_store: Some(Sourced::new( + AuthCredentialsStoreMode::Keyring, + source.clone(), + )), + chatgpt_base_url: Some(Sourced::new( + "https://managed.example/backend-api/".to_string(), + source.clone(), + )), + sqlite_home: Some(Sourced::new(sqlite_home, source.clone())), + log_dir: Some(Sourced::new(log_dir, source.clone())), + model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())), + model_provider: Some(Sourced::new("gateway".to_string(), source.clone())), + model_providers: Some(Sourced::new(HashMap::new(), source.clone())), + check_for_update_on_startup: Some(Sourced::new( + /*value*/ false, + source.clone(), + )), + allow_login_shell: Some(Sourced::new(/*value*/ false, source.clone())), + feedback: Some(Sourced::new(feedback, source.clone())), + allowed_approval_policies: Some(Sourced::new( + allowed_approval_policies, + source.clone() + )), + allowed_approvals_reviewers: Some(Sourced::new( + allowed_approvals_reviewers, + source.clone(), + )), + allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)), + allowed_permission_profiles: Some(Sourced::new( + BTreeMap::from([("managed".to_string(), true)]), + source.clone(), + )), + default_permissions: Some(Sourced::new("managed".to_string(), source.clone(),)), + allowed_web_search_modes: Some(Sourced::new( + allowed_web_search_modes, + enforce_source.clone(), + )), + allow_managed_hooks_only: Some(Sourced::new( + /*value*/ true, + enforce_source.clone(), + )), + allow_browser_and_computer_use: Some(Sourced::new( + /*value*/ false, + enforce_source.clone(), + )), + allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)), + allow_remote_control: Some(Sourced::new( + /*value*/ false, + enforce_source.clone(), + )), + computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), + browser_use: Some(Sourced::new(browser_use, enforce_source.clone())), + in_app_browser: None, + windows: Some(Sourced::new(windows, enforce_source.clone())), + feature_requirements: Some(Sourced::new( + feature_requirements, + enforce_source.clone(), + )), + hooks: None, + mcp_servers: None, + plugins: None, + marketplaces: None, + apps: None, + rules: None, + enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)), + network: None, + application: None, + permissions: None, + auto_review: Some(Sourced::new(auto_review, source.clone())), + models: Some(Sourced::new(models, source.clone())), + additional_developer_instructions: Some(Sourced::new( + additional_developer_instructions, + source.clone(), + )), + guardian_policy_config: Some(Sourced::new(guardian_policy_config, source)), + } + ); + } + + #[test] + fn merge_unset_fields_fills_missing_values() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + "#, + )?; + + let source_location = RequirementSource::MdmManagedPreferences { + domain: "com.codex".to_string(), + key: "allowed_approval_policies".to_string(), + }; + + let mut empty_target = ConfigRequirementsWithSources::default(); + empty_target.merge_unset_fields(source_location.clone(), source); + assert_eq!( + empty_target, + ConfigRequirementsWithSources { + allowed_approval_policies: Some(Sourced::new( + vec![AskForApproval::OnRequest], + source_location, + )), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_permission_profiles: None, + default_permissions: None, + allowed_web_search_modes: None, + allow_managed_hooks_only: None, + allow_appshots: None, + allow_remote_control: None, + computer_use: None, + browser_use: None, + in_app_browser: None, + windows: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + marketplaces: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + application: None, + permissions: None, + models: None, + guardian_policy_config: None, + ..Default::default() + } + ); + Ok(()) + } + + #[test] + fn merge_unset_fields_does_not_overwrite_existing_values() -> Result<()> { + let existing_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut populated_target = ConfigRequirementsWithSources::default(); + let populated_requirements: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["never"] + "#, + )?; + populated_target.merge_unset_fields(existing_source.clone(), populated_requirements); + + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + "#, + )?; + let source_location = RequirementSource::MdmManagedPreferences { + domain: "com.codex".to_string(), + key: "allowed_approval_policies".to_string(), + }; + populated_target.merge_unset_fields(source_location, source); + + assert_eq!( + populated_target, + ConfigRequirementsWithSources { + allowed_approval_policies: Some(Sourced::new( + vec![AskForApproval::Never], + existing_source, + )), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_permission_profiles: None, + default_permissions: None, + allowed_web_search_modes: None, + allow_managed_hooks_only: None, + allow_appshots: None, + allow_remote_control: None, + computer_use: None, + browser_use: None, + in_app_browser: None, + windows: None, + feature_requirements: None, + hooks: None, + mcp_servers: None, + plugins: None, + marketplaces: None, + apps: None, + rules: None, + enforce_residency: None, + network: None, + application: None, + permissions: None, + models: None, + guardian_policy_config: None, + ..Default::default() + } + ); + Ok(()) + } + + #[test] + fn merge_unset_fields_ignores_blank_guardian_override() { + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields( + RequirementSource::LegacyManagedConfigTomlFromMdm, + ConfigRequirementsToml { + guardian_policy_config: Some(" \n\t".to_string()), + ..Default::default() + }, + ); + target.merge_unset_fields( + RequirementSource::SystemRequirementsToml { + file: system_requirements_toml_file_for_test() + .expect("system requirements.toml path"), + }, + ConfigRequirementsToml { + guardian_policy_config: Some("Use the system guardian policy.".to_string()), + ..Default::default() + }, + ); + + assert_eq!( + target.guardian_policy_config, + Some(Sourced::new( + "Use the system guardian policy.".to_string(), + RequirementSource::SystemRequirementsToml { + file: system_requirements_toml_file_for_test() + .expect("system requirements.toml path"), + }, + )), + ); + } + + #[test] + fn deserialize_guardian_policy_config() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" +guardian_policy_config = """ +Use the cloud-managed guardian policy. +""" +"#, + )?; + + assert_eq!( + requirements.guardian_policy_config.as_deref(), + Some("Use the cloud-managed guardian policy.\n") + ); + Ok(()) + } + + #[test] + fn blank_guardian_policy_config_is_empty() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" +guardian_policy_config = """ + +""" +"#, + )?; + + assert!(requirements.is_empty()); + Ok(()) + } + + #[test] + fn allowed_approvals_reviewers_is_not_empty() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" +allowed_approvals_reviewers = ["user"] +"#, + )?; + + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn deserialize_filesystem_deny_read_requirements() -> Result<()> { + let deny_read_0 = if cfg!(windows) { + r"C:\Users\alice\.gitconfig" + } else { + "/home/alice/.gitconfig" + }; + let deny_read_1 = if cfg!(windows) { + r"C:\Users\alice\.ssh" + } else { + "/home/alice/.ssh" + }; + let toml_str = format!( + r#" + [permissions.filesystem] + deny_read = [{deny_read_0:?}, {deny_read_1:?}] + "# + ); + + let config: ConfigRequirementsToml = from_str(&toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.filesystem, + Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![ + AbsolutePathBuf::from_absolute_path(deny_read_0)?.into(), + AbsolutePathBuf::from_absolute_path(deny_read_1)?.into(), + ], + }, + RequirementSource::Unknown, + )) + ); + + Ok(()) + } + + #[test] + fn deserialize_filesystem_deny_read_glob_requirements() -> Result<()> { + let temp_dir = std::env::temp_dir(); + let _guard = AbsolutePathBufGuard::new(&temp_dir); + let config: ConfigRequirementsToml = from_str( + r#" + [permissions.filesystem] + deny_read = ["./private/**/*.txt"] + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.filesystem, + Some(Sourced::new( + FilesystemConstraints { + deny_read: vec![ + FilesystemDenyReadPattern::from_input("./private/**/*.txt") + .expect("normalize glob pattern"), + ], + }, + RequirementSource::Unknown, + )) + ); + Ok(()) + } + + #[test] + fn deserialize_apps_requirements() -> Result<()> { + let toml_str = r#" + [apps.connector_123123] + enabled = false + "#; + let requirements: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + requirements.apps, + Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: Some(false), + tools: None, + }, + )]), + }) + ); + Ok(()) + } + + #[test] + fn deserialize_apps_tool_requirements() -> Result<()> { + let toml_str = r#" + [apps.connector_123123.tools."calendar/list_events"] + approval_mode = "approve" + "#; + let requirements: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + requirements.apps, + Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: None, + tools: Some(AppToolsRequirementsToml { + tools: BTreeMap::from([( + "calendar/list_events".to_string(), + AppToolRequirementToml { + approval_mode: Some(AppToolApproval::Approve), + analytics_result_source: None, + }, + )]), + }), + }, + )]), + }) + ); + Ok(()) + } + + #[test] + fn app_tool_result_source_requirements_parse_and_merge() -> Result<()> { + let rule = r#" + [apps.connector_123123.tools."messages/search"] + analytics_result_source = { format = "detailed_message_search_v1", type = "message_room" } + "#; + let requirements: ConfigRequirementsToml = from_str(rule)?; + assert!(!requirements.is_empty()); + let source = requirements.apps.expect("apps should be present"); + + for higher_rule in [ + None, + Some(("unsupported", "other_resource")), + Some(("detailed_message_search_v1", "other_resource")), + ] { + let mut merged = source.clone(); + merged + .apps + .get_mut("connector_123123") + .expect("app should be present") + .tools + .as_mut() + .expect("tools should be present") + .tools + .get_mut("messages/search") + .expect("tool should be present") + .analytics_result_source = higher_rule.map(|(format, source_type)| { + from_str(&format!("format = {format:?}\ntype = {source_type:?}")) + .expect("complete source rule should parse, including unknown formats") + }); + let expected = if higher_rule.is_none() { + source.clone() + } else { + merged.clone() + }; + + merge_app_requirements_descending(&mut merged, source.clone()); + + assert_eq!(merged, expected); + } + + for incomplete_rule in [ + r#"format = "detailed_message_search_v1""#, + r#"type = "message_room""#, + ] { + assert!(from_str::(incomplete_rule).is_err()); + } + Ok(()) + } + + fn apps_requirements(entries: &[(&str, Option)]) -> AppsRequirementsToml { + AppsRequirementsToml { + apps: entries + .iter() + .map(|(app_id, enabled)| { + ( + (*app_id).to_string(), + AppRequirementToml { + enabled: *enabled, + tools: None, + }, + ) + }) + .collect(), + } + } + + fn app_tool_requirements( + app_id: &str, + tool_name: &str, + approval_mode: AppToolApproval, + ) -> AppsRequirementsToml { + AppsRequirementsToml { + apps: BTreeMap::from([( + app_id.to_string(), + AppRequirementToml { + enabled: None, + tools: Some(AppToolsRequirementsToml { + tools: BTreeMap::from([( + tool_name.to_string(), + AppToolRequirementToml { + approval_mode: Some(approval_mode), + analytics_result_source: None, + }, + )]), + }), + }, + )]), + } + } + + #[test] + fn merge_app_requirements_descending_unions_distinct_apps() { + let mut merged = apps_requirements(&[("connector_high", Some(false))]); + let lower = apps_requirements(&[("connector_low", Some(true))]); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[ + ("connector_high", Some(false)), + ("connector_low", Some(true)) + ]), + ); + } + + #[test] + fn merge_app_requirements_descending_prefers_false_from_lower_precedence() { + let mut merged = apps_requirements(&[("connector_123123", Some(true))]); + let lower = apps_requirements(&[("connector_123123", Some(false))]); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(false))]), + ); + } + + #[test] + fn merge_app_requirements_descending_keeps_higher_true_when_lower_is_unset() { + let mut merged = apps_requirements(&[("connector_123123", Some(true))]); + let lower = apps_requirements(&[("connector_123123", None)]); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(true))]), + ); + } + + #[test] + fn merge_app_requirements_descending_uses_lower_value_when_higher_missing() { + let mut merged = apps_requirements(&[]); + let lower = apps_requirements(&[("connector_123123", Some(true))]); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(true))]), + ); + } + + #[test] + fn merge_app_requirements_descending_preserves_higher_false_when_lower_missing_app() { + let mut merged = apps_requirements(&[("connector_123123", Some(false))]); + let lower = apps_requirements(&[]); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + apps_requirements(&[("connector_123123", Some(false))]), + ); + } + + #[test] + fn merge_app_requirements_descending_preserves_higher_tool_approval_mode() { + let mut merged = app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ); + let lower = app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Prompt, + ); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ) + ); + } + + #[test] + fn merge_app_requirements_descending_uses_lower_tool_approval_when_higher_missing() { + let mut merged = apps_requirements(&[("connector_123123", None)]); + let lower = app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ) + ); + } + + #[test] + fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() { + let higher_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let lower_source = RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }; + let mut target = ConfigRequirementsWithSources::default(); + + target.merge_unset_fields( + higher_source.clone(), + ConfigRequirementsToml { + apps: Some(apps_requirements(&[ + ("connector_high", Some(true)), + ("connector_shared", Some(true)), + ])), + ..Default::default() + }, + ); + target.merge_unset_fields( + lower_source, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[ + ("connector_low", Some(false)), + ("connector_shared", Some(false)), + ])), + ..Default::default() + }, + ); + + let apps = target.apps.expect("apps should be present"); + assert_eq!( + apps.value, + apps_requirements(&[ + ("connector_high", Some(true)), + ("connector_low", Some(false)), + ("connector_shared", Some(false)), + ]) + ); + assert_eq!(apps.source, higher_source); + } + + #[test] + fn merge_unset_fields_apps_empty_higher_source_does_not_block_lower_disables() { + let mut target = ConfigRequirementsWithSources::default(); + + target.merge_unset_fields( + RequirementSource::LegacyManagedConfigTomlFromMdm, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[])), + ..Default::default() + }, + ); + target.merge_unset_fields( + RequirementSource::LegacyManagedConfigTomlFromMdm, + ConfigRequirementsToml { + apps: Some(apps_requirements(&[("connector_123123", Some(false))])), + ..Default::default() + }, + ); + + assert_eq!( + target.apps.map(|apps| apps.value), + Some(apps_requirements(&[("connector_123123", Some(false))])), + ); + } + + #[test] + fn constraint_error_includes_requirement_source() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + allowed_approvals_reviewers = ["auto_review"] + allowed_sandbox_modes = ["read-only"] + "#, + )?; + + let requirements_toml_file = system_requirements_toml_file_for_test()?; + let source_location = RequirementSource::SystemRequirementsToml { + file: requirements_toml_file, + }; + + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(source_location.clone(), source); + let requirements = ConfigRequirements::try_from(target)?; + + assert_eq!( + requirements.approval_policy.can_set(&AskForApproval::Never), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: source_location.clone(), + }) + ); + assert_eq!( + requirements + .permission_profile + .can_set(&PermissionProfile::Disabled), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly]".into(), + requirement_source: source_location.clone(), + }) + ); + assert_eq!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::User), + Err(ConstraintError::InvalidValue { + field_name: "approvals_reviewer", + candidate: "User".into(), + allowed: "[AutoReview]".into(), + requirement_source: source_location, + }) + ); + + Ok(()) + } + + #[test] + fn constraint_error_includes_composite_requirement_source() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + "#, + )?; + + let source_location = RequirementSource::composite([ + RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }, + RequirementSource::LegacyManagedConfigTomlFromMdm, + ]); + + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(source_location.clone(), source); + let requirements = ConfigRequirements::try_from(target)?; + + assert_eq!( + requirements.approval_policy.can_set(&AskForApproval::Never), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "Never".into(), + allowed: "[OnRequest]".into(), + requirement_source: source_location, + }) + ); + + Ok(()) + } + + #[test] + fn constrained_fields_store_requirement_source() -> Result<()> { + let source: ConfigRequirementsToml = from_str( + r#" + allowed_approval_policies = ["on-request"] + allowed_approvals_reviewers = ["auto_review"] + allowed_sandbox_modes = ["read-only"] + allowed_web_search_modes = ["cached"] + enforce_residency = "us" + [features] + personality = true + "#, + )?; + + let source_location = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields(source_location.clone(), source); + let requirements = ConfigRequirements::try_from(target)?; + + assert_eq!( + requirements.approval_policy.source, + Some(source_location.clone()) + ); + assert_eq!( + requirements.approvals_reviewer.source, + Some(source_location.clone()) + ); + assert_eq!( + requirements.permission_profile.source, + Some(source_location.clone()) + ); + assert_eq!( + requirements.web_search_mode.source, + Some(source_location.clone()) + ); + assert_eq!( + requirements + .feature_requirements + .as_ref() + .map(|requirements| requirements.source.clone()), + Some(source_location.clone()) + ); + assert_eq!(requirements.enforce_residency.source, Some(source_location)); + + Ok(()) + } + + #[test] + fn deserialize_allowed_approval_policies() -> Result<()> { + let toml_str = r#" + allowed_approval_policies = ["on-request", "never"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approval_policy.value(), + AskForApproval::OnRequest, + "currently, there is no way to specify the default value for approval policy in the toml, so it picks the first allowed value" + ); + assert!( + requirements + .approval_policy + .can_set(&AskForApproval::OnRequest) + .is_ok() + ); + assert_eq!( + requirements + .approval_policy + .can_set(&AskForApproval::UnlessTrusted), + Err(ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: "UnlessTrusted".into(), + allowed: "[OnRequest, Never]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert!( + requirements + .permission_profile + .can_set(&PermissionProfile::read_only()) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn deserialize_allowed_approvals_reviewers() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = ["auto_review", "user"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approvals_reviewer.value(), + ApprovalsReviewer::AutoReview, + "currently, there is no way to specify the default value for approvals reviewer in the toml, so it picks the first allowed value" + ); + assert!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::AutoReview) + .is_ok() + ); + assert!( + requirements + .approvals_reviewer + .can_set(&ApprovalsReviewer::User) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn deserialize_allowed_windows_sandbox_implementations() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = ["elevated"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.windows_sandbox_mode.value(), + Some(WindowsSandboxModeToml::Elevated) + ); + assert!( + requirements + .windows_sandbox_mode + .can_set(&Some(WindowsSandboxModeToml::Elevated)) + .is_ok() + ); + assert!( + requirements + .windows_sandbox_mode + .can_set(&Some(WindowsSandboxModeToml::Unelevated)) + .is_err() + ); + assert!(requirements.windows_sandbox_mode.can_set(&None).is_err()); + + Ok(()) + } + + #[test] + fn empty_allowed_windows_sandbox_implementations_is_rejected() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + ConfigRequirements::try_from(with_unknown_source(config)), + Err(ConstraintError::EmptyField { + field_name: "windows.allowed_sandbox_implementations".to_string(), + }) + ); + + Ok(()) + } + + #[test] + fn allowed_windows_sandbox_implementations_prefer_elevated_fallback() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = ["unelevated", "elevated"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.windows_sandbox_mode.value(), + Some(WindowsSandboxModeToml::Elevated) + ); + + Ok(()) + } + + #[test] + fn deserialize_legacy_allowed_approvals_reviewer() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = ["guardian_subagent", "user"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.approvals_reviewer.value(), + ApprovalsReviewer::AutoReview + ); + + Ok(()) + } + + #[test] + fn empty_allowed_approvals_reviewers_is_rejected() -> Result<()> { + let toml_str = r#" + allowed_approvals_reviewers = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let err = ConfigRequirements::try_from(with_unknown_source(config)) + .expect_err("empty approvals reviewer allow-list should be rejected"); + + assert_eq!( + err, + ConstraintError::EmptyField { + field_name: "allowed_approvals_reviewers".to_string(), + } + ); + + Ok(()) + } + + #[test] + fn deserialize_allowed_sandbox_modes() -> Result<()> { + let toml_str = r#" + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + assert!( + requirements + .permission_profile + .can_set(&PermissionProfile::read_only()) + .is_ok() + ); + let workspace_write_profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path(root)?], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + assert!( + requirements + .permission_profile + .can_set(&workspace_write_profile) + .is_ok() + ); + assert_eq!( + requirements + .permission_profile + .can_set(&PermissionProfile::Disabled), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert_eq!( + requirements + .permission_profile + .can_set(&PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "ExternalSandbox".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + + Ok(()) + } + + #[test] + fn deserialize_remote_sandbox_config_requires_hostname_patterns_list() -> Result<()> { + let toml_str = r#" + [[remote_sandbox_config]] + hostname_patterns = ["*.org", "runner-??.ci"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + config.remote_sandbox_config, + Some(vec![RemoteSandboxConfigToml { + hostname_patterns: vec!["*.org".to_string(), "runner-??.ci".to_string()], + allowed_sandbox_modes: vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + ], + }]) + ); + + let err = from_str::( + r#" + [[remote_sandbox_config]] + hostname_patterns = "*.org" + allowed_sandbox_modes = ["read-only"] + "#, + ) + .expect_err("hostname_patterns should be list-only"); + assert!( + err.to_string().contains("invalid type: string"), + "unexpected error: {err}" + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_first_match_overrides_top_level() -> Result<()> { + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut requirements_toml: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-*.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-01.example.com"] + allowed_sandbox_modes = ["read-only", "danger-full-access"] + "#, + )?; + requirements_toml.apply_remote_sandbox_config(Some("BUILD-01.EXAMPLE.COM.")); + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), requirements_toml); + + assert_eq!( + requirements_with_sources + .allowed_sandbox_modes + .as_ref() + .map(|sourced| sourced.value.clone()), + Some(vec![ + SandboxModeRequirement::ReadOnly, + SandboxModeRequirement::WorkspaceWrite, + ]) + ); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + let workspace_write_profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path(root)?], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + assert!( + requirements + .permission_profile + .can_set(&workspace_write_profile) + .is_ok() + ); + assert_eq!( + requirements + .permission_profile + .can_set(&PermissionProfile::Disabled), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly, WorkspaceWrite]".into(), + requirement_source: source, + }) + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_non_match_preserves_top_level() -> Result<()> { + let mut requirements_toml: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + + [[remote_sandbox_config]] + hostname_patterns = ["build-*.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#, + )?; + requirements_toml.apply_remote_sandbox_config(Some("laptop.example.com")); + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, requirements_toml); + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + + assert_eq!( + requirements + .permission_profile + .can_set(&PermissionProfile::Disabled), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "DangerFullAccess".into(), + allowed: "[ReadOnly]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + + Ok(()) + } + + #[test] + fn remote_sandbox_config_does_not_override_higher_precedence_sandbox_modes() -> Result<()> { + let high_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut high_precedence: ConfigRequirementsToml = from_str( + r#" + allowed_sandbox_modes = ["read-only"] + "#, + )?; + high_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com")); + + let mut low_precedence: ConfigRequirementsToml = from_str( + r#" + [[remote_sandbox_config]] + hostname_patterns = ["runner-*.ci.example.com"] + allowed_sandbox_modes = ["read-only", "workspace-write"] + "#, + )?; + low_precedence.apply_remote_sandbox_config(Some("runner-01.ci.example.com")); + + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(high_source.clone(), high_precedence); + requirements_with_sources.merge_unset_fields(RequirementSource::Unknown, low_precedence); + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + + assert_eq!( + requirements + .permission_profile + .can_set(&PermissionProfile::workspace_write()), + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "WorkspaceWrite".into(), + allowed: "[ReadOnly]".into(), + requirement_source: high_source, + }) + ); + + Ok(()) + } + + #[test] + fn deserialize_allowed_web_search_modes() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = ["cached"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Cached); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Live), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Live".into(), + allowed: "[Disabled, Cached]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Cached) + .is_ok() + ); + + Ok(()) + } + + #[test] + fn allowed_web_search_modes_supports_indexed() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" + allowed_web_search_modes = ["indexed"] + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!(requirements.web_search_mode.value(), WebSearchMode::Indexed); + for mode in [WebSearchMode::Disabled, WebSearchMode::Indexed] { + assert!(requirements.web_search_mode.can_set(&mode).is_ok()); + } + for mode in [WebSearchMode::Cached, WebSearchMode::Live] { + assert_eq!( + requirements.web_search_mode.can_set(&mode), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: format!("{mode:?}"), + allowed: "[Disabled, Indexed]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + } + + Ok(()) + } + + #[test] + fn allowed_web_search_modes_allows_disabled() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = ["disabled"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.web_search_mode.value(), + WebSearchMode::Disabled + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Cached), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Cached".into(), + allowed: "[Disabled]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + + #[test] + fn allowed_web_search_modes_empty_restricts_to_disabled() -> Result<()> { + let toml_str = r#" + allowed_web_search_modes = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.web_search_mode.value(), + WebSearchMode::Disabled + ); + assert!( + requirements + .web_search_mode + .can_set(&WebSearchMode::Disabled) + .is_ok() + ); + assert_eq!( + requirements.web_search_mode.can_set(&WebSearchMode::Cached), + Err(ConstraintError::InvalidValue { + field_name: "web_search_mode", + candidate: "Cached".into(), + allowed: "[Disabled]".into(), + requirement_source: RequirementSource::Unknown, + }) + ); + Ok(()) + } + + #[test] + fn deserialize_feature_requirements() -> Result<()> { + let toml_str = r#" + [features] + apps = false + personality = true + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.feature_requirements, + Some(Sourced::new( + FeatureRequirementsToml { + entries: BTreeMap::from([ + ("apps".to_string(), false), + ("personality".to_string(), true), + ]), + }, + RequirementSource::Unknown, + )) + ); + + Ok(()) + } + + #[test] + fn deserialize_managed_hooks_requirements() -> Result<()> { + let toml_str = r#" +managed_dir = "/enterprise/hooks" +windows_managed_dir = 'C:\enterprise\hooks' + +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /enterprise/hooks/pre.py" +timeout = 10 +statusMessage = "checking" + "#; + let hooks: ManagedHooksRequirementsToml = from_str(toml_str)?; + + assert_eq!( + hooks.managed_dir.as_deref(), + Some(std::path::Path::new("/enterprise/hooks")) + ); + assert_eq!(hooks.handler_count(), 1); + assert_eq!(hooks.hooks.pre_tool_use.len(), 1); + Ok(()) + } + + #[test] + fn merge_unset_fields_does_not_overwrite_existing_hooks() -> Result<()> { + let mut target = ConfigRequirementsWithSources::default(); + target.merge_unset_fields( + RequirementSource::LegacyManagedConfigTomlFromMdm, + from_str::( + r#" +[hooks] +managed_dir = "/cloud/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /cloud/hooks/pre.py" + "#, + )?, + ); + target.merge_unset_fields( + RequirementSource::SystemRequirementsToml { + file: system_requirements_toml_file_for_test()?, + }, + from_str::( + r#" +[hooks] +managed_dir = "/system/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /system/hooks/pre.py" + "#, + )?, + ); + + assert_eq!( + target + .hooks + .as_ref() + .and_then(|hooks| hooks.value.managed_dir.as_ref()) + .map(std::path::PathBuf::as_path), + Some(std::path::Path::new("/cloud/hooks")) + ); + assert_eq!( + target.hooks.as_ref().map(|hooks| hooks.source.clone()), + Some(RequirementSource::LegacyManagedConfigTomlFromMdm) + ); + Ok(()) + } + + #[test] + fn managed_hooks_constraint_rejects_drift() -> Result<()> { + let config: ConfigRequirementsToml = from_str( + r#" +[hooks] +managed_dir = "/enterprise/hooks" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /enterprise/hooks/pre.py" + "#, + )?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + let mut managed_hooks = requirements + .managed_hooks + .expect("expected managed hooks requirements"); + + let err = managed_hooks + .set(ManagedHooksRequirementsToml { + managed_dir: Some(std::path::PathBuf::from("/other/hooks")), + windows_managed_dir: None, + hooks: HookEventsToml::default(), + }) + .expect_err("managed hooks should reject drift"); + + assert!(matches!( + err, + ConstraintError::InvalidValue { + field_name: "hooks", + requirement_source: RequirementSource::Unknown, + .. + } + )); + Ok(()) + } + + #[test] + fn network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { + let toml_str = r#" + [experimental_network] + enabled = true + allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true + managed_allowed_domains_only = true + allow_local_binding = false + + [experimental_network.domains] + "api.example.com" = "allow" + "*.openai.com" = "allow" + "blocked.example.com" = "deny" + + [experimental_network.unix_sockets] + "/tmp/example.sock" = "allow" + "/tmp/blocked.sock" = "deny" + + [[experimental_network.header_injections]] + host = "api.example.com" + methods = ["POST"] + path_prefixes = ["/console/v1"] + + [experimental_network.header_injections.headers] + "x-statsig-change-source" = "codex" + "#; + + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let sourced_network = requirements + .network + .expect("network requirements should be preserved as constraints"); + + assert_eq!(sourced_network.source, source); + assert_eq!(sourced_network.value.enabled, Some(true)); + assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!( + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) + ); + assert_eq!( + sourced_network.value.managed_allowed_domains_only, + Some(true) + ); + assert_eq!( + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/blocked.sock".to_string(), + NetworkUnixSocketPermissionToml::Deny, + ), + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ]), + }) + ); + assert_eq!(sourced_network.value.allow_local_binding, Some(false)); + assert_eq!( + sourced_network.value.header_injections, + Some(vec![NetworkHeaderInjectionToml { + host: "api.example.com".to_string(), + methods: vec!["POST".to_string()], + path_prefixes: vec!["/console/v1".to_string()], + headers: BTreeMap::from([( + "x-statsig-change-source".to_string(), + "codex".to_string(), + )]), + }]) + ); + let debug = format!("{:?}", sourced_network.value.header_injections); + assert!(debug.contains("x-statsig-change-source")); + assert!(!debug.contains("codex")); + + Ok(()) + } + + #[test] + fn legacy_network_requirements_are_preserved_as_constraints_with_source() -> Result<()> { + let toml_str = r#" + [experimental_network] + enabled = true + allow_upstream_proxy = false + dangerously_allow_all_unix_sockets = true + allowed_domains = ["api.example.com", "*.openai.com"] + managed_allowed_domains_only = true + denied_domains = ["blocked.example.com"] + allow_unix_sockets = ["/tmp/example.sock"] + allow_local_binding = false + "#; + + let source = RequirementSource::LegacyManagedConfigTomlFromMdm; + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source.clone(), from_str(toml_str)?); + + let requirements = ConfigRequirements::try_from(requirements_with_sources)?; + let sourced_network = requirements + .network + .expect("network requirements should be preserved as constraints"); + + assert_eq!(sourced_network.source, source); + assert_eq!(sourced_network.value.enabled, Some(true)); + assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false)); + assert_eq!( + sourced_network.value.dangerously_allow_all_unix_sockets, + Some(true) + ); + assert_eq!( + sourced_network.value.domains.as_ref(), + Some(&NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) + ); + assert_eq!( + sourced_network.value.managed_allowed_domains_only, + Some(true) + ); + assert_eq!( + sourced_network.value.unix_sockets.as_ref(), + Some(&NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + )]), + }) + ); + assert_eq!(sourced_network.value.allow_local_binding, Some(false)); + + Ok(()) + } + + #[test] + fn mixed_legacy_and_canonical_network_requirements_are_rejected() { + let err = from_str::( + r#" + [experimental_network] + allowed_domains = ["api.example.com"] + + [experimental_network.domains] + "*.openai.com" = "allow" + "#, + ) + .expect_err("mixed network domain shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.domains` cannot be combined"), + "unexpected error: {err:#}" + ); + + let err = from_str::( + r#" + [experimental_network] + allow_unix_sockets = ["/tmp/example.sock"] + + [experimental_network.unix_sockets] + "/tmp/another.sock" = "allow" + "#, + ) + .expect_err("mixed network unix socket shapes should fail"); + + assert!( + err.to_string() + .contains("`experimental_network.unix_sockets` cannot be combined"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn network_permission_containers_project_allowed_and_denied_entries() { + let domains = NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "*.openai.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "blocked.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }; + let unix_sockets = NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermissionToml::Deny, + ), + ]), + }; + + assert_eq!( + domains.allowed_domains(), + Some(vec![ + "*.openai.com".to_string(), + "api.example.com".to_string() + ]) + ); + assert_eq!( + domains.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); + assert_eq!( + NetworkDomainPermissionsToml { + entries: BTreeMap::from([( + "api.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + )]), + } + .denied_domains(), + None + ); + assert_eq!( + unix_sockets.allow_unix_sockets(), + vec!["/tmp/example.sock".to_string()] + ); + } + + #[test] + fn deserialize_mcp_server_requirements() -> Result<()> { + let toml_str = r#" + [mcp_servers.docs] + description = "ignored legacy field" + + [mcp_servers.docs.identity] + command = "codex-mcp" + + [mcp_servers.remote.identity] + url = "https://example.com/mcp" + "#; + let requirements: ConfigRequirements = + with_unknown_source(from_str(toml_str)?).try_into()?; + + assert_eq!( + requirements.mcp_servers, + Some(Sourced::new( + BTreeMap::from([ + ( + "docs".to_string(), + McpServerRequirement::Identity { + identity: McpServerIdentity::Command { + command: "codex-mcp".to_string(), + }, + }, + ), + ( + "remote".to_string(), + McpServerRequirement::Identity { + identity: McpServerIdentity::Url { + url: "https://example.com/mcp".to_string(), + }, + }, + ), + ]), + RequirementSource::Unknown, + )) + ); + Ok(()) + } + + #[test] + fn deserialize_mcp_server_matcher_requirements() -> Result<()> { + let toml_str = r#" + [mcp_servers.internal_mcp_proxy.identity] + command = { executable = "company-cli", args = [ + { match = "exact", value = "mcp" }, + { match = "exact", value = "proxy" }, + { match = "exact", value = "--server" }, + { match = "regex", expression = '^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$' }, + ] } + "#; + let requirements: ConfigRequirements = + with_unknown_source(from_str(toml_str)?).try_into()?; + + assert_eq!( + requirements.mcp_servers, + Some(Sourced::new( + BTreeMap::from([( + "internal_mcp_proxy".to_string(), + McpServerRequirement::Command(McpServerCommandMatcher { + executable: "company-cli".to_string(), + args: vec![ + McpServerValueMatcher::Exact { + value: "mcp".to_string(), + }, + McpServerValueMatcher::Exact { + value: "proxy".to_string(), + }, + McpServerValueMatcher::Exact { + value: "--server".to_string(), + }, + McpServerValueMatcher::Regex { + expression: r"^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$" + .to_string(), + }, + ], + }), + )]), + RequirementSource::Unknown, + )) + ); + Ok(()) + } + + #[test] + fn invalid_mcp_server_requirement_regex_reports_the_server_name_and_source() -> Result<()> { + let toml_str = r#" + [mcp_servers.broken_rule.identity] + url = { match = "regex", expression = "[" } + "#; + + let err = ConfigRequirements::try_from(with_unknown_source(from_str(toml_str)?)) + .expect_err("invalid matcher regex should fail requirements normalization"); + let ConstraintError::McpServerRequirementParse { + server_name, + requirement_source, + reason, + } = err + else { + panic!("unexpected error: {err:?}"); + }; + + assert_eq!(server_name, "broken_rule"); + assert_eq!(requirement_source, RequirementSource::Unknown); + assert!(reason.contains("invalid regex `[`"), "{reason}"); + Ok(()) + } + + #[test] + fn deserialize_plugin_mcp_server_requirements() -> Result<()> { + let toml_str = r#" + [plugins."sample@test".mcp_servers.sample.identity] + command = "sample-mcp" + + [plugins."remote@test".mcp_servers.remote.identity] + url = "https://example.com/mcp" + "#; + let requirements: ConfigRequirements = + with_unknown_source(from_str(toml_str)?).try_into()?; + + assert_eq!( + requirements.plugins, + Some(Sourced::new( + BTreeMap::from([ + ( + "remote@test".to_string(), + PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "remote".to_string(), + McpServerRequirement::Identity { + identity: McpServerIdentity::Url { + url: "https://example.com/mcp".to_string(), + }, + }, + )])), + }, + ), + ( + "sample@test".to_string(), + PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "sample".to_string(), + McpServerRequirement::Identity { + identity: McpServerIdentity::Command { + command: "sample-mcp".to_string(), + }, + }, + )])), + }, + ), + ]), + RequirementSource::Unknown, + )) + ); + Ok(()) + } + + #[test] + fn deserialize_plugin_mcp_server_matcher_requirement() -> Result<()> { + let toml_str = r#" + [plugins."sample@test".mcp_servers.internal_proxy.identity] + command = { executable = "company-cli", args = [ + { match = "exact", value = "mcp" }, + { match = "regex", expression = '^https://[a-z]+\.example\.com$' }, + ] } + "#; + let requirements: ConfigRequirements = + with_unknown_source(from_str(toml_str)?).try_into()?; + + assert_eq!( + requirements.plugins, + Some(Sourced::new( + BTreeMap::from([( + "sample@test".to_string(), + PluginRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "internal_proxy".to_string(), + McpServerRequirement::Command(McpServerCommandMatcher { + executable: "company-cli".to_string(), + args: vec![ + McpServerValueMatcher::Exact { + value: "mcp".to_string(), + }, + McpServerValueMatcher::Regex { + expression: r"^https://[a-z]+\.example\.com$".to_string(), + }, + ], + }), + )])), + }, + )]), + RequirementSource::Unknown, + )) + ); + Ok(()) + } + + #[test] + fn invalid_plugin_mcp_server_regex_reports_plugin_and_server_name() -> Result<()> { + let toml_str = r#" + [plugins."sample@test".mcp_servers.broken_rule.identity] + url = { match = "regex", expression = "[" } + "#; + + let err = ConfigRequirements::try_from(with_unknown_source(from_str(toml_str)?)) + .expect_err("invalid plugin MCP regex should fail requirements normalization"); + let ConstraintError::McpServerRequirementParse { + server_name, + requirement_source, + reason, + } = err + else { + panic!("unexpected error: {err:?}"); + }; + + assert_eq!(server_name, "sample@test/broken_rule"); + assert_eq!(requirement_source, RequirementSource::Unknown); + assert!(reason.contains("invalid regex `[`"), "{reason}"); + Ok(()) + } + + #[test] + fn deserialize_exec_policy_requirements() -> Result<()> { + let toml_str = r#" + [rules] + prefix_rules = [ + { pattern = [{ token = "rm" }], decision = "forbidden" }, + ] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + let policy = requirements.exec_policy.expect("exec policy").value; + + assert_eq!( + policy.as_ref().check(&tokens(&["rm", "-rf"]), &|_| { + panic!("rule should match so heuristic should not be called"); + }), + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["rm"]), + decision: Decision::Forbidden, + resolved_program: None, + justification: None, + }], + } + ); + + Ok(()) + } + + #[test] + fn exec_policy_error_includes_requirement_source() -> Result<()> { + let toml_str = r#" + [rules] + prefix_rules = [ + { pattern = [{ token = "rm" }] }, + ] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements_toml_file = system_requirements_toml_file_for_test()?; + let source_location = RequirementSource::SystemRequirementsToml { + file: requirements_toml_file, + }; + + let mut requirements_with_sources = ConfigRequirementsWithSources::default(); + requirements_with_sources.merge_unset_fields(source_location.clone(), config); + let err = ConfigRequirements::try_from(requirements_with_sources) + .expect_err("invalid exec policy"); + + assert_eq!( + err, + ConstraintError::ExecPolicyParse { + requirement_source: source_location, + reason: "rules prefix_rule at index 0 is missing a decision".to_string(), + } + ); + + Ok(()) + } +} diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs new file mode 100644 index 0000000000000000000000000000000000000000..d5572f3ccd64a1d490a450f8ab7fc0b6aad8e5a5 --- /dev/null +++ b/codex-rs/config/src/config_toml.rs @@ -0,0 +1,1073 @@ +//! Schema-heavy configuration TOML types used by Codex. + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::num::NonZeroU64; +use std::path::Path; + +use crate::HooksToml; +use crate::browser_use::BrowserUseConfigToml; +use crate::computer_use::ComputerUseConfigToml; +use crate::permissions_toml::PermissionsToml; +use crate::profile_toml::ConfigProfile; +use crate::types::AnalyticsConfigToml; +use crate::types::ApprovalsReviewer; +use crate::types::AppsConfigToml; +use crate::types::AuthCredentialsStoreMode; +use crate::types::FeedbackConfigToml; +use crate::types::History; +use crate::types::MarketplaceConfig; +use crate::types::McpEnterpriseManagedAuthConfig; +use crate::types::McpServerConfig; +use crate::types::MemoriesToml; +use crate::types::Notice; +use crate::types::OAuthCredentialsStoreMode; +use crate::types::OtelConfigToml; +use crate::types::PluginConfig; +use crate::types::SandboxWorkspaceWrite; +use crate::types::ShellEnvironmentPolicyToml; +use crate::types::SkillsConfig; +use crate::types::ToolSuggestConfig; +use crate::types::Tui; +use crate::types::UriBasedFileOpener; +use crate::types::WindowsToml; +use codex_features::FeaturesToml; +use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; +use codex_model_provider_info::AMAZON_BEDROCK_RUNTIME_PROVIDER_ID; +use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; +use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; +use codex_model_provider_info::ModelProviderInfo; +use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; +use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; +use codex_model_provider_info::OPENAI_PROVIDER_ID; +use codex_protocol::config_types::AutoCompactTokenLimitScope; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path::normalize_for_path_comparison; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; +use serde_json::Value as JsonValue; + +const RESERVED_MODEL_PROVIDER_IDS: [&str; 5] = [ + AMAZON_BEDROCK_PROVIDER_ID, + AMAZON_BEDROCK_RUNTIME_PROVIDER_ID, + OPENAI_PROVIDER_ID, + OLLAMA_OSS_PROVIDER_ID, + LMSTUDIO_OSS_PROVIDER_ID, +]; + +pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; + +fn default_history() -> Option { + Some(History::default()) +} + +const fn default_project_doc_max_bytes() -> Option { + Some(DEFAULT_PROJECT_DOC_MAX_BYTES) +} + +fn default_project_doc_fallback_filenames() -> Option> { + Some(Vec::new()) +} + +const fn default_hide_agent_reasoning() -> Option { + Some(false) +} + +const fn default_true() -> bool { + true +} + +/// Backward-compatible shape for ChatGPT workspace login restrictions in config.toml. +#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(untagged)] +pub enum ForcedChatgptWorkspaceIds { + Single(String), + Multiple(Vec), +} + +impl ForcedChatgptWorkspaceIds { + pub fn into_vec(self) -> Vec { + match self { + Self::Single(value) => vec![value], + Self::Multiple(values) => values, + } + } +} + +impl<'de> Deserialize<'de> for ForcedChatgptWorkspaceIds { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Single(String), + Multiple(Vec), + } + + match Repr::deserialize(deserializer)? { + Repr::Single(value) if value.contains(',') => Err(D::Error::custom( + "forced_chatgpt_workspace_id must be a single workspace ID string or a TOML list \ +of strings; comma-separated strings are not supported. Use \ +`forced_chatgpt_workspace_id = [\"123e4567-e89b-42d3-a456-426614174000\", \ +\"123e4567-e89b-42d3-a456-426614174001\"]` instead.", + )), + Repr::Single(value) => Ok(Self::Single(value)), + Repr::Multiple(values) => Ok(Self::Multiple(values)), + } + } +} + +/// Orchestrator-owned feature settings. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OrchestratorToml { + pub skills: Option, + pub mcp: Option, +} + +/// Settings for a feature owned by the orchestrator. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OrchestratorFeatureToml { + pub enabled: Option, +} + +/// Base config deserialized from ~/.codex/config.toml. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigToml { + /// Optional override of model selection. + pub model: Option, + /// Review model override used by the `/review` feature. + pub review_model: Option, + + /// Provider to use from the model_providers map. + pub model_provider: Option, + + /// Size of the context window for the model, in tokens. + pub model_context_window: Option, + + /// Token usage threshold triggering auto-compaction of conversation history. + pub model_auto_compact_token_limit: Option, + + /// Controls whether the auto-compaction limit applies to the full context or + /// only to tokens after the carried prefix in the current compaction window. + pub model_auto_compact_token_limit_scope: Option, + + /// Default approval policy for executing commands. + #[schemars(with = "Option")] + pub approval_policy: Option, + + /// Configures who approval requests are routed to for review once they have + /// been escalated. This does not disable separate safety checks such as + /// ARC. + pub approvals_reviewer: Option, + + /// Optional policy instructions for the guardian auto-reviewer. + #[serde(default)] + pub auto_review: Option, + + pub browser_use: Option, + + pub computer_use: Option, + + #[serde(default)] + pub shell_environment_policy: ShellEnvironmentPolicyToml, + + /// Whether the model may request a login shell for shell-based tools. + /// Default to `true` + /// + /// If `true`, the model may request a login shell (`login = true`), and + /// omitting `login` defaults to using a login shell. + /// If `false`, the model can never use a login shell: `login = true` + /// requests are rejected, and omitting `login` defaults to a non-login + /// shell. + pub allow_login_shell: Option, + + /// Sandbox mode to use. + pub sandbox_mode: Option, + + /// Allow macOS sandbox writable roots at or beneath CODEX_HOME to traverse + /// symlinks. Read only from the host's user config at startup; defaults to false. + /// This grants no write access by itself, but trusts symlink targets even if + /// they change between commands or lie outside CODEX_HOME. + /// This setting has no effect on Linux or Windows. + pub allow_symlinked_codex_home: Option, + + /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. + pub sandbox_workspace_write: Option, + + /// Default permissions profile to apply. Names starting with `:` refer to + /// built-in profiles; other names are resolved from the `[permissions]` + /// table. + pub default_permissions: Option, + + /// Named permissions profiles. + #[serde(default)] + pub permissions: Option, + + /// Optional external command to spawn for end-user notifications. + #[serde(default)] + pub notify: Option>, + + /// System instructions. + pub instructions: Option, + + /// Developer instructions inserted as a `developer` role message. + #[serde(default)] + pub developer_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_permissions_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_apps_instructions: Option, + + /// Whether to inject the `` developer block. + pub include_collaboration_mode_instructions: Option, + + /// Whether to inject the `` user block. + pub include_environment_context: Option, + + /// Optional path to a file containing model instructions that will override + /// the built-in instructions for the selected model. Users are STRONGLY + /// DISCOURAGED from using this field, as deviating from the instructions + /// sanctioned by Codex will likely degrade model performance. + pub model_instructions_file: Option, + + /// Compact prompt used for history compaction. + pub compact_prompt: Option, + + /// When set, restricts ChatGPT login to one or more workspace identifiers. + #[serde(default)] + pub forced_chatgpt_workspace_id: Option, + + /// When set, restricts the login mechanism users may use. + #[serde(default)] + pub forced_login_method: Option, + + /// Preferred backend for storing CLI auth credentials. + /// file (default): Use a file in the Codex home directory. + /// keyring: Use an OS-specific keyring service. + /// auto: Use the keyring if available, otherwise use a file. + #[serde(default)] + pub cli_auth_credentials_store: Option, + + /// Definition for MCP servers that Codex can reach out to for tool calls. + #[serde(default)] + // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`. + #[schemars(schema_with = "crate::schema::mcp_servers_schema")] + pub mcp_servers: HashMap, + + /// Trusted enterprise IdP shared by EMA-enabled MCP servers and plugins. + #[serde(default)] + pub mcp_enterprise_managed_auth: Option, + + /// Preferred backend for storing MCP OAuth credentials. + /// keyring: Use an OS-specific keyring service. + /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 + /// file: Use a file in the Codex home directory. + /// auto (default): Use the OS-specific keyring service if available, otherwise use a file. + #[serde(default)] + pub mcp_oauth_credentials_store: Option, + + /// Optional fixed port for the local HTTP callback server used during MCP OAuth login. + /// When unset, Codex will bind to an ephemeral port chosen by the OS. + pub mcp_oauth_callback_port: Option, + + /// Optional redirect URI to use during MCP OAuth login. + /// When set, this URI is used in the OAuth authorization request instead + /// of the local listener address. The local callback listener still binds + /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided). + pub mcp_oauth_callback_url: Option, + + /// Milliseconds to wait for optional MCP servers while building the initial tool catalog. + /// + /// Defaults to 1000. Set to 0 to disable the shared grace and wait for each + /// server's configured `startup_timeout_sec` instead. + pub mcp_optional_startup_grace_ms: Option, + + /// User-defined provider entries that extend the built-in list. Built-in + /// IDs cannot be overridden. + #[serde(default, deserialize_with = "deserialize_model_providers")] + pub model_providers: HashMap, + + /// Maximum total bytes of project instruction content across all selected environments. + #[serde(default = "default_project_doc_max_bytes")] + pub project_doc_max_bytes: Option, + + /// Ordered list of fallback filenames to look for when AGENTS.md is missing. + #[serde(default = "default_project_doc_fallback_filenames")] + pub project_doc_fallback_filenames: Option>, + + /// Token budget applied when storing tool/function outputs in the context manager. + pub tool_output_token_limit: Option, + + /// Maximum poll window for background terminal output (`write_stdin`), in milliseconds. + /// Default: `300000` (5 minutes). + pub background_terminal_max_timeout: Option, + + /// Seconds a thread must have no subscribers and no activity before app-server + /// unloads it. Defaults to 60; zero unloads immediately. Changes require a server restart. + pub thread_unload_delay_secs: Option, + + /// Deprecated: ignored. + #[schemars(skip)] + pub js_repl_node_path: Option, + + /// Deprecated: ignored. + #[schemars(skip)] + pub js_repl_node_module_dirs: Option>, + + /// Profile to use from the `profiles` map. + pub profile: Option, + + /// Named profiles to facilitate switching between different configurations. + #[serde(default)] + pub profiles: HashMap, + + /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. + #[serde(default = "default_history")] + pub history: Option, + + /// Directory where Codex stores the SQLite state DB. + /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. + pub sqlite_home: Option, + + /// Directory where Codex writes log files. Setting this value explicitly + /// also enables the TUI text log in this directory. + /// Defaults to `$CODEX_HOME/log`. + pub log_dir: Option, + + /// Optional URI-based file opener. If set, citations to files in the model + /// output will be hyperlinked using the specified URI scheme. + pub file_opener: Option, + + /// Collection of settings that are specific to the TUI. + pub tui: Option, + + /// When set to `true`, `AgentReasoning` events will be hidden from the + /// UI/output. Defaults to `false`. + #[serde(default = "default_hide_agent_reasoning")] + pub hide_agent_reasoning: Option, + + /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. + /// Defaults to `false`. + pub show_raw_agent_reasoning: Option, + + pub model_reasoning_effort: Option, + pub plan_mode_reasoning_effort: Option, + pub model_reasoning_summary: Option, + /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`). + pub model_verbosity: Option, + + /// Optional path to a JSON model catalog (applied on startup only). + /// Per-thread `config` overrides are accepted but do not reapply this (no-ops). + pub model_catalog_json: Option, + + /// Deprecated: `friendly` and `pragmatic` no longer select a style. + pub personality: Option, + + /// Optional explicit service tier request id for new turns (for example + /// `default`, `priority`, or `flex`; legacy `fast` also works). + pub service_tier: Option, + + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). + pub chatgpt_base_url: Option, + + /// Optional product SKU forwarded on host-owned Codex Apps MCP requests. + pub apps_mcp_product_sku: Option, + + /// Bounded, product-owned metadata attached to every Responses API request. + pub responses_api_metadata: Option>, + + /// Orchestrator-owned feature settings. + pub orchestrator: Option, + + /// Base URL override for the built-in `openai` model provider. + pub openai_base_url: Option, + + /// Machine-local realtime audio device preferences used by realtime voice. + #[serde(default)] + pub audio: Option, + + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport base URL (the `Op::RealtimeConversation` + /// `/v1/realtime` + /// connection) without changing normal provider HTTP requests. + pub experimental_realtime_ws_base_url: Option, + /// Experimental / do not use. Overrides only the WebRTC realtime call + /// creation base URL. This is separate from `experimental_realtime_ws_base_url` + /// because WebRTC call creation is HTTP, while sideband control is websocket. + pub experimental_realtime_webrtc_call_base_url: Option, + /// Experimental / do not use. Selects the realtime websocket model/snapshot + /// used for the `Op::RealtimeConversation` connection. + pub experimental_realtime_ws_model: Option, + /// Experimental / do not use. Realtime websocket session selection. + /// `version` controls v1/v2 and `type` controls conversational/transcription. + #[serde(default)] + pub realtime: Option, + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport instructions (the `Op::RealtimeConversation` + /// `/ws` session.update instructions) without changing normal prompts. + pub experimental_realtime_ws_backend_prompt: Option, + /// Experimental / do not use. Replaces the synthesized realtime startup + /// context appended to websocket session instructions. An empty string + /// disables startup context injection entirely. + pub experimental_realtime_ws_startup_context: Option, + /// Experimental / do not use. Replaces the built-in realtime start + /// instructions inserted into developer messages when realtime becomes + /// active. + pub experimental_realtime_start_instructions: Option, + + /// Removed. Former remote thread-store endpoint setting kept only so we can + /// fail fast instead of silently falling back to local persistence. + #[schemars(skip)] + pub experimental_thread_store_endpoint: Option, + + /// Experimental / do not use. Selects the thread store implementation. + pub experimental_thread_store: Option, + pub projects: Option>, + + /// Controls the web search tool mode: disabled, cached, indexed, or live. + pub web_search: Option, + + /// Nested tools section for feature toggles + pub tools: Option, + + /// Additional discoverable tools that can be suggested for installation. + pub tool_suggest: Option, + + /// Agent-related settings (thread limits, etc.). + pub agents: Option, + + /// Goal-related settings. + pub goals: Option, + + /// Memories subsystem settings. + pub memories: Option, + + /// User-level skill config entries keyed by SKILL.md path. + pub skills: Option, + + /// Lifecycle hooks configured inline in TOML plus user-level overrides. + pub hooks: Option, + + /// User-level plugin config entries keyed by plugin name. + #[serde(default)] + pub plugins: HashMap, + + /// User-level marketplace entries keyed by marketplace name. + #[serde(default)] + pub marketplaces: HashMap, + + /// Centralized feature flags (new). Prefer this over individual toggles. + #[serde(default)] + // Injects known feature keys into the schema and forbids unknown keys. + #[schemars(schema_with = "crate::schema::features_schema")] + pub features: Option, + + /// Suppress warnings about unstable (under development) features. + pub suppress_unstable_features_warning: Option, + + /// Compatibility-only settings retained so legacy `ghost_snapshot` + /// config still loads. + #[serde(default)] + pub ghost_snapshot: Option, + + /// Markers used to detect the project root when searching parent + /// directories for `.codex` folders. Defaults to [".git"] when unset. + #[serde(default)] + pub project_root_markers: Option>, + + /// When `true`, checks for Codex updates on startup and surfaces update prompts. + /// Set to `false` only if your Codex updates are centrally managed. + /// Defaults to `true`. + pub check_for_update_on_startup: Option, + + /// Legacy fallback for `tui.disable_paste_burst`. Prefer the setting under `[tui]`. + pub disable_paste_burst: Option, + + /// When `false`, disables analytics across Codex product surfaces in this machine. + /// Defaults to `true`. + pub analytics: Option, + + /// When `false`, disables feedback collection across Codex product surfaces. + /// Defaults to `true`. + pub feedback: Option, + + /// Settings for app-specific controls. + #[serde(default)] + pub apps: Option, + + /// Opaque desktop settings stored alongside the rest of config.toml. + #[serde(default)] + pub desktop: Option>, + + /// OTEL configuration. + pub otel: Option, + + /// Windows-specific configuration. + #[serde(default)] + pub windows: Option, + + /// Collection of in-product notices (different from notifications) + /// See [`crate::types::Notice`] for more details + pub notice: Option, + + pub experimental_compact_prompt_file: Option, + pub experimental_use_unified_exec_tool: Option, + /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". + pub oss_provider: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThreadStoreToml { + Local {}, + #[schemars(skip)] + InMemory { + id: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct AutoReviewToml { + /// Additional policy instructions inserted into the guardian prompt. + pub policy: Option, + /// Experimental full Guardian prompt template containing the tenant policy placeholder. + pub experimental_policy_template: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ProjectConfig { + pub trust_level: Option, +} + +impl ProjectConfig { + pub fn is_trusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Trusted)) + } + + pub fn is_untrusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Untrusted)) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RealtimeAudioConfig { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeWsMode { + #[default] + Conversational, + Transcription, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeTransport { + #[default] + #[serde(rename = "webrtc")] + WebRtc, + Websocket, +} + +pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; +pub use codex_protocol::protocol::RealtimeVoice; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeConfig { + pub version: RealtimeWsVersion, + #[serde(rename = "type")] + pub session_type: RealtimeWsMode, + pub transport: RealtimeTransport, + pub voice: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeToml { + pub version: Option, + #[serde(rename = "type")] + pub session_type: Option, + pub transport: Option, + pub voice: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RealtimeAudioToml { + pub microphone: Option, + pub speaker: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolsToml { + #[serde( + default, + deserialize_with = "deserialize_optional_web_search_tool_config" + )] + pub web_search: Option, + pub experimental_request_user_input: Option, + pub update_plan: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ExperimentalRequestUserInput { + #[serde(default = "default_true")] + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct UpdatePlanToolConfig { + #[serde(default)] + pub enabled: bool, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum WebSearchToolConfigInput { + Enabled(bool), + Config(WebSearchToolConfig), +} + +fn deserialize_optional_web_search_tool_config<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + + Ok(match value { + None => None, + Some(WebSearchToolConfigInput::Enabled(enabled)) => { + let _ = enabled; + None + } + Some(WebSearchToolConfigInput::Config(config)) => Some(config), + }) +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct GoalsToml { + /// Maximum token budget allowed for a goal and default budget for new goals. + pub max_goal_token_budget: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentsToml { + /// Whether multi-agent tools are enabled. Defaults to true. + /// An enabled `features.multi_agent_v2` setting takes precedence. + pub enabled: Option, + /// Maximum number of spawned agent threads that can be open concurrently per session. + /// When unset, the selected multi-agent backend uses its default. + #[serde(alias = "max_threads")] + #[schemars(range(min = 1))] + pub max_concurrent_threads_per_session: Option, + /// Maximum nesting depth for V1 agent threads. Ignored by V2. + pub max_depth: Option, + /// Default model for spawned subagents when the spawn call does not select one. + pub default_subagent_model: Option, + /// Default reasoning effort for spawned subagents when the spawn call does not select one. + pub default_subagent_reasoning_effort: Option, + /// Removed agent-job setting retained as a no-op for compatibility. + #[schemars(skip)] + pub job_max_runtime_seconds: Option, + /// Whether to record a model-visible message when an agent turn is interrupted. + /// Defaults to true. + pub interrupt_message: Option, + + /// User-defined role declarations keyed by role name. + /// + /// Example: + /// ```toml + /// [agents.researcher] + /// description = "Research-focused role." + /// config_file = "./agents/researcher.toml" + /// nickname_candidates = ["Herodotus", "Ibn Battuta"] + /// ``` + #[serde(default, flatten)] + pub roles: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentRoleToml { + /// Human-facing role documentation used in spawn tool guidance. + /// Required unless supplied by the referenced agent role file. + pub description: Option, + + /// Path to a role-specific config layer. + /// Relative paths are resolved relative to the `config.toml` that defines them. + pub config_file: Option, + + /// Candidate nicknames for agents spawned with this role. + pub nickname_candidates: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct GhostSnapshotToml { + /// Legacy no-op setting retained for compatibility. + #[serde(alias = "ignore_untracked_files_over_bytes")] + pub ignore_large_untracked_files: Option, + /// Legacy no-op setting retained for compatibility. + #[serde(alias = "large_untracked_dir_warning_threshold")] + pub ignore_large_untracked_dirs: Option, + /// Legacy no-op setting retained for compatibility. + pub disable_warnings: Option, +} + +impl ConfigToml { + /// Derive the effective permission profile from legacy sandbox config. + /// + /// Call this only after ruling out `default_permissions`: named + /// `[permissions]` profiles must be compiled through the permissions + /// profile pipeline, not reconstructed from `sandbox_mode`. + pub async fn derive_permission_profile( + &self, + sandbox_mode_override: Option, + windows_sandbox_level: WindowsSandboxLevel, + active_project: Option<&ProjectConfig>, + permission_profile_constraint: Option<&crate::Constrained>, + ) -> PermissionProfile { + let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode); + let resolved_sandbox_mode = configured_sandbox_mode + .or_else(|| { + // If no sandbox_mode is set but this directory has a trust decision, + // default to workspace-write except on unsandboxed Windows where we + // default to read-only. + active_project + .filter(|project| project.is_trusted() || project.is_untrusted()) + .map(|_| { + if cfg!(target_os = "windows") + && windows_sandbox_level == WindowsSandboxLevel::Disabled + { + SandboxMode::ReadOnly + } else { + SandboxMode::WorkspaceWrite + } + }) + }) + .unwrap_or_default(); + let effective_sandbox_mode = if cfg!(target_os = "windows") + // If the experimental Windows sandbox is enabled, do not force a downgrade. + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite) + { + SandboxMode::ReadOnly + } else { + resolved_sandbox_mode + }; + + let permission_profile = match effective_sandbox_mode { + SandboxMode::ReadOnly => PermissionProfile::read_only(), + SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { + Some(SandboxWorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) => { + let network_policy = if *network_access { + NetworkSandboxPolicy::Enabled + } else { + NetworkSandboxPolicy::Restricted + }; + PermissionProfile::workspace_write_with( + writable_roots, + network_policy, + *exclude_tmpdir_env_var, + *exclude_slash_tmp, + ) + } + None => PermissionProfile::workspace_write(), + }, + SandboxMode::DangerFullAccess => PermissionProfile::Disabled, + }; + if configured_sandbox_mode.is_none() + && let Some(constraint) = permission_profile_constraint + && let Err(err) = constraint.can_set(&permission_profile) + { + tracing::warn!( + error = %err, + "default sandbox policy is disallowed by requirements; falling back to required default" + ); + PermissionProfile::read_only() + } else { + permission_profile + } + } + + /// Resolves the cwd to an existing project, or returns None if ConfigToml + /// does not contain a project corresponding to cwd or the resolved git repo + /// root for cwd. + pub fn get_active_project( + &self, + resolved_cwd: &Path, + repo_root: Option<&Path>, + ) -> Option { + let projects = self.projects.as_ref()?; + + for normalized_cwd in normalized_project_lookup_keys(resolved_cwd) { + if let Some(project_config) = project_config_for_lookup_key(projects, &normalized_cwd) { + return Some(project_config); + } + } + + if let Some(repo_root) = repo_root { + for normalized_repo_root in normalized_project_lookup_keys(repo_root) { + if let Some(project_config_for_root) = + project_config_for_lookup_key(projects, &normalized_repo_root) + { + return Some(project_config_for_root); + } + } + } + + None + } +} + +/// Canonicalize the path and convert it to a string to be used as a key in the +/// projects trust map. On Windows, strips UNC, when possible, to try to ensure +/// that different paths that point to the same location have the same key. +fn normalized_project_lookup_keys(path: &Path) -> Vec { + let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string()); + let normalized_canonical_path = normalize_project_lookup_key( + normalize_for_path_comparison(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string(), + ); + if normalized_path == normalized_canonical_path { + vec![normalized_canonical_path] + } else { + vec![normalized_canonical_path, normalized_path] + } +} + +fn normalize_project_lookup_key(key: String) -> String { + if cfg!(windows) { + key.to_ascii_lowercase() + } else { + key + } +} + +fn project_config_for_lookup_key( + projects: &HashMap, + lookup_key: &str, +) -> Option { + if let Some(project_config) = projects.get(lookup_key) { + return Some(project_config.clone()); + } + + let mut normalized_matches: Vec<_> = projects + .iter() + .filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key) + .collect(); + normalized_matches.sort_by_key(|(key, _)| *key); + normalized_matches + .first() + .map(|(_, project_config)| (**project_config).clone()) +} + +pub fn validate_reserved_model_provider_ids( + model_providers: &HashMap, +) -> Result<(), String> { + let mut conflicts = model_providers + .keys() + .filter(|key| { + !matches!( + key.as_str(), + AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID + ) && RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str()) + }) + .map(|key| format!("`{key}`")) + .collect::>(); + conflicts.sort_unstable(); + if conflicts.is_empty() { + Ok(()) + } else { + Err(format!( + "model_providers contains reserved built-in provider IDs: {}. \ +Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).", + conflicts.join(", ") + )) + } +} + +pub fn validate_model_providers( + model_providers: &HashMap, +) -> Result<(), String> { + validate_reserved_model_provider_ids(model_providers)?; + for (key, provider) in model_providers { + if matches!( + key.as_str(), + AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID + ) { + provider + .validate_bedrock_override() + .map_err(|message| format!("model_providers.{key} {message}"))?; + } else { + if provider.aws.is_some() { + return Err(format!( + "model_providers.{key}: provider aws is only supported for \ +`{AMAZON_BEDROCK_PROVIDER_ID}` or `{AMAZON_BEDROCK_RUNTIME_PROVIDER_ID}`" + )); + } + if provider.name.trim().is_empty() { + return Err(format!( + "model_providers.{key}: provider name must not be empty" + )); + } + } + provider + .validate() + .map_err(|message| format!("model_providers.{key}: {message}"))?; + } + Ok(()) +} + +fn deserialize_model_providers<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let model_providers = HashMap::::deserialize(deserializer)?; + validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?; + Ok(model_providers) +} + +#[cfg(test)] +#[path = "bedrock_runtime_tests.rs"] +mod bedrock_runtime_tests; + +pub fn validate_oss_provider(provider: &str) -> std::io::Result<()> { + match provider { + LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => Ok(()), + LEGACY_OLLAMA_CHAT_PROVIDER_ID => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + OLLAMA_CHAT_PROVIDER_REMOVED_ERROR, + )), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}" + ), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000"; + const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001"; + + #[test] + fn thread_unload_delay_requires_nonnegative_seconds() { + for value in ["-1", "1.5", "\"60\""] { + let error = + toml::from_str::(&format!("thread_unload_delay_secs = {value}")) + .expect_err("idle timeout must be a nonnegative integer"); + assert!(error.to_string().contains("thread_unload_delay_secs")); + } + } + + #[test] + fn forced_chatgpt_workspace_id_accepts_single_string() { + let config: ConfigToml = toml::from_str(&format!( + r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A}""# + )) + .expect("single workspace id should deserialize"); + + assert_eq!( + config + .forced_chatgpt_workspace_id + .expect("workspace id should be set") + .into_vec(), + vec![WORKSPACE_ID_A.to_string()] + ); + } + + #[test] + fn forced_chatgpt_workspace_id_accepts_string_list() { + let config: ConfigToml = toml::from_str(&format!( + r#"forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"]"# + )) + .expect("workspace id list should deserialize"); + + assert_eq!( + config + .forced_chatgpt_workspace_id + .expect("workspace ids should be set") + .into_vec(), + vec![WORKSPACE_ID_A.to_string(), WORKSPACE_ID_B.to_string()] + ); + } + + #[test] + fn forced_chatgpt_workspace_id_rejects_comma_separated_string() { + let err = toml::from_str::(&format!( + r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A},{WORKSPACE_ID_B}""# + )) + .expect_err("comma-separated string should be rejected"); + + let message = err.to_string(); + assert!(message.contains("TOML list of strings")); + assert!(message.contains("comma-separated strings are not supported")); + } + + #[test] + fn amazon_bedrock_auth_command_must_not_be_empty() { + let err = toml::from_str::( + r#" +[model_providers.amazon-bedrock.auth] +command = " " +"#, + ) + .expect_err("empty Amazon Bedrock auth command should be rejected"); + + assert!( + err.to_string().contains( + "model_providers.amazon-bedrock: provider auth.command must not be empty" + ) + ); + } +} diff --git a/codex-rs/config/src/constraint.rs b/codex-rs/config/src/constraint.rs new file mode 100644 index 0000000000000000000000000000000000000000..e2c7f9430b587a4ab770d1207faf7a1cd3f96747 --- /dev/null +++ b/codex-rs/config/src/constraint.rs @@ -0,0 +1,344 @@ +use std::fmt; +use std::sync::Arc; + +use crate::config_requirements::RequirementSource; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ConstraintError { + #[error( + "invalid value for `{field_name}`: `{candidate}` is not in the allowed set {allowed} (set by {requirement_source})" + )] + InvalidValue { + field_name: &'static str, + candidate: String, + allowed: String, + requirement_source: RequirementSource, + }, + + #[error("To use model `{model}`, you need to use auto review.")] + AutoReviewRequired { model: String }, + + #[error("field `{field_name}` cannot be empty")] + EmptyField { field_name: String }, + + #[error("invalid rules in requirements (set by {requirement_source}): {reason}")] + ExecPolicyParse { + requirement_source: RequirementSource, + reason: String, + }, + + #[error( + "invalid requirement for MCP server `{server_name}` (set by {requirement_source}): {reason}" + )] + McpServerRequirementParse { + server_name: String, + requirement_source: RequirementSource, + reason: String, + }, +} + +impl ConstraintError { + pub fn empty_field(field_name: impl Into) -> Self { + Self::EmptyField { + field_name: field_name.into(), + } + } +} + +pub type ConstraintResult = Result; + +impl From for std::io::Error { + fn from(err: ConstraintError) -> Self { + std::io::Error::new(std::io::ErrorKind::InvalidInput, err) + } +} + +type ConstraintValidator = dyn Fn(&T) -> ConstraintResult<()> + Send + Sync; +/// A ConstraintNormalizer is a function which transforms a value into another of the same type. +/// `Constrained` uses normalizers to transform values to satisfy constraints or enforce values. +type ConstraintNormalizer = dyn Fn(T) -> T + Send + Sync; + +#[derive(Clone)] +pub struct Constrained { + value: T, + validator: Arc>, + normalizer: Option>>, +} + +impl Constrained { + pub fn new( + initial_value: T, + validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static, + ) -> ConstraintResult { + let validator: Arc> = Arc::new(validator); + validator(&initial_value)?; + Ok(Self { + value: initial_value, + validator, + normalizer: None, + }) + } + + /// normalized creates a `Constrained` value with a normalizer function and a validator that allows any value. + pub fn normalized( + initial_value: T, + normalizer: impl Fn(T) -> T + Send + Sync + 'static, + ) -> ConstraintResult { + let validator: Arc> = Arc::new(|_| Ok(())); + let normalizer: Arc> = Arc::new(normalizer); + let normalized = normalizer(initial_value); + validator(&normalized)?; + Ok(Self { + value: normalized, + validator, + normalizer: Some(normalizer), + }) + } + + pub fn allow_any(initial_value: T) -> Self { + Self { + value: initial_value, + validator: Arc::new(|_| Ok(())), + normalizer: None, + } + } + + pub fn allow_only(only_value: T) -> Self + where + T: Clone + fmt::Debug + PartialEq + 'static, + { + let allowed_value = only_value.clone(); + Self { + value: only_value, + validator: Arc::new(move |candidate| { + if candidate == &allowed_value { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "", + candidate: format!("{candidate:?}"), + allowed: format!("[{allowed_value:?}]"), + requirement_source: RequirementSource::Unknown, + }) + } + }), + normalizer: None, + } + } + + /// Allow any value of T, using T's Default as the initial value. + pub fn allow_any_from_default() -> Self + where + T: Default, + { + Self::allow_any(T::default()) + } + + pub fn get(&self) -> &T { + &self.value + } + + pub fn value(&self) -> T + where + T: Copy, + { + self.value + } + + pub fn can_set(&self, candidate: &T) -> ConstraintResult<()> { + (self.validator)(candidate) + } + + /// Composes an additional validator onto the current constraint. + /// + /// The existing value must satisfy the combined validator before it is installed. + pub fn add_validator( + &mut self, + validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static, + ) -> ConstraintResult<()> + where + T: 'static, + { + let existing_validator = self.validator.clone(); + let combined_validator: Arc> = Arc::new(move |candidate| { + existing_validator(candidate)?; + validator(candidate) + }); + + combined_validator(&self.value)?; + self.validator = combined_validator; + Ok(()) + } + + pub fn set(&mut self, value: T) -> ConstraintResult<()> { + let value = if let Some(normalizer) = &self.normalizer { + normalizer(value) + } else { + value + }; + (self.validator)(&value)?; + self.value = value; + Ok(()) + } +} + +impl std::ops::Deref for Constrained { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl fmt::Debug for Constrained { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Constrained") + .field("value", &self.value) + .finish() + } +} + +impl PartialEq for Constrained { + fn eq(&self, other: &Self) -> bool { + self.value == other.value + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn invalid_value(candidate: impl Into, allowed: impl Into) -> ConstraintError { + ConstraintError::InvalidValue { + field_name: "", + candidate: candidate.into(), + allowed: allowed.into(), + requirement_source: RequirementSource::Unknown, + } + } + + #[test] + fn constrained_allow_any_accepts_any_value() { + let mut constrained = Constrained::allow_any(/*initial_value*/ 5); + constrained + .set(/*value*/ -10) + .expect("allow any accepts all values"); + assert_eq!(constrained.value(), -10); + } + + #[test] + fn constrained_allow_any_default_uses_default_value() { + let constrained = Constrained::::allow_any_from_default(); + assert_eq!(constrained.value(), 0); + } + + #[test] + fn constrained_allow_only_rejects_different_values() { + let mut constrained = Constrained::allow_only(/*only_value*/ 5); + constrained + .set(/*value*/ 5) + .expect("allowed value should be accepted"); + + let err = constrained + .set(/*value*/ 6) + .expect_err("different value should be rejected"); + assert_eq!(err, invalid_value("6", "[5]")); + assert_eq!(constrained.value(), 5); + } + + #[test] + fn constrained_normalizer_applies_on_init_and_set() -> anyhow::Result<()> { + let mut constrained = + Constrained::normalized(/*initial_value*/ -1, |value| value.max(0))?; + assert_eq!(constrained.value(), 0); + constrained.set(/*value*/ -5)?; + assert_eq!(constrained.value(), 0); + constrained.set(/*value*/ 10)?; + assert_eq!(constrained.value(), 10); + Ok(()) + } + + #[test] + fn constrained_add_validator_composes_with_existing_validator() -> anyhow::Result<()> { + let mut constrained = Constrained::new(/*initial_value*/ 5, |value: &i32| { + if *value >= 0 { + Ok(()) + } else { + Err(ConstraintError::empty_field("value")) + } + })?; + constrained.add_validator(|value| { + if *value <= 10 { + Ok(()) + } else { + Err(ConstraintError::empty_field("value")) + } + })?; + + assert_eq!(constrained.can_set(&7), Ok(())); + assert_eq!( + constrained.can_set(&11), + Err(ConstraintError::empty_field("value")) + ); + assert_eq!( + constrained.can_set(&-1), + Err(ConstraintError::empty_field("value")) + ); + + Ok(()) + } + + #[test] + fn constrained_new_rejects_invalid_initial_value() { + let result = Constrained::new(/*initial_value*/ 0, |value| { + if *value > 0 { + Ok(()) + } else { + Err(invalid_value(value.to_string(), "positive values")) + } + }); + + assert_eq!(result, Err(invalid_value("0", "positive values"))); + } + + #[test] + fn constrained_set_rejects_invalid_value_and_leaves_previous() { + let mut constrained = Constrained::new(/*initial_value*/ 1, |value| { + if *value > 0 { + Ok(()) + } else { + Err(invalid_value(value.to_string(), "positive values")) + } + }) + .expect("initial value should be accepted"); + + let err = constrained + .set(/*value*/ -5) + .expect_err("negative values should be rejected"); + assert_eq!(err, invalid_value("-5", "positive values")); + assert_eq!(constrained.value(), 1); + } + + #[test] + fn constrained_can_set_allows_probe_without_setting() { + let constrained = Constrained::new(/*initial_value*/ 1, |value| { + if *value > 0 { + Ok(()) + } else { + Err(invalid_value(value.to_string(), "positive values")) + } + }) + .expect("initial value should be accepted"); + + constrained + .can_set(&2) + .expect("can_set should accept positive value"); + let err = constrained + .can_set(&-1) + .expect_err("can_set should reject negative value"); + assert_eq!(err, invalid_value("-1", "positive values")); + assert_eq!(constrained.value(), 1); + } +} diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs new file mode 100644 index 0000000000000000000000000000000000000000..003693288765cf836f7af5c4e280d52fda65bdb7 --- /dev/null +++ b/codex-rs/config/src/diagnostics.rs @@ -0,0 +1,495 @@ +//! Helpers for mapping config parse/validation failures to file locations and +//! rendering them in a user-friendly way. + +use crate::ConfigLayerEntry; +use crate::ConfigLayerSource; +use crate::ConfigLayerStack; +use crate::format_config_layer_source; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde::de::DeserializeOwned; +use serde_path_to_error::Path as SerdePath; +use serde_path_to_error::Segment as SerdeSegment; +use std::fmt; +use std::fmt::Write; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use toml_edit::Document; +use toml_edit::Item; +use toml_edit::Table; +use toml_edit::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextPosition { + pub line: usize, + pub column: usize, +} + +/// Text range in 1-based line/column coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigError { + pub path: PathBuf, + pub range: TextRange, + pub message: String, +} + +impl ConfigError { + pub fn new(path: PathBuf, range: TextRange, message: impl Into) -> Self { + Self { + path, + range, + message: message.into(), + } + } +} + +#[derive(Debug)] +pub struct ConfigLoadError { + error: ConfigError, + source: Option, +} + +impl ConfigLoadError { + pub fn new(error: ConfigError, source: Option) -> Self { + Self { error, source } + } + + pub fn config_error(&self) -> &ConfigError { + &self.error + } +} + +impl fmt::Display for ConfigLoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}:{}: {}", + self.error.path.display(), + self.error.range.start.line, + self.error.range.start.column, + self.error.message + ) + } +} + +impl std::error::Error for ConfigLoadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|err| err as &dyn std::error::Error) + } +} + +#[derive(Clone, Copy)] +pub(crate) enum ConfigDiagnosticSource<'a> { + Path(&'a Path), + DisplayName(&'a str), +} + +impl ConfigDiagnosticSource<'_> { + pub(crate) fn to_path_buf(self) -> PathBuf { + match self { + ConfigDiagnosticSource::Path(path) => path.to_path_buf(), + ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name), + } + } +} + +pub fn io_error_from_config_error( + kind: io::ErrorKind, + error: ConfigError, + source: Option, +) -> io::Error { + io::Error::new(kind, ConfigLoadError::new(error, source)) +} + +pub fn config_error_from_toml( + path: impl AsRef, + contents: &str, + err: toml::de::Error, +) -> ConfigError { + config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err) +} + +pub(crate) fn config_error_from_toml_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, + err: toml::de::Error, +) -> ConfigError { + let range = err + .span() + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + ConfigError::new(source.to_path_buf(), range, err.message()) +} + +pub fn config_error_from_typed_toml( + path: impl AsRef, + contents: &str, +) -> Option { + config_error_from_typed_toml_for_source::( + ConfigDiagnosticSource::Path(path.as_ref()), + contents, + ) +} + +fn config_error_from_typed_toml_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, +) -> Option { + let deserializer = match toml::de::Deserializer::parse(contents) { + Ok(deserializer) => deserializer, + Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)), + }; + + let result: Result = serde_path_to_error::deserialize(deserializer); + match result { + Ok(_) => None, + Err(err) => { + let path_hint = err.path().clone(); + let toml_err: toml::de::Error = err.into_inner(); + let range = span_for_config_path(contents, &path_hint) + .or_else(|| toml_err.span()) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + source.to_path_buf(), + range, + toml_err.message(), + )) + } + } +} + +pub async fn first_layer_config_error( + layers: &ConfigLayerStack, + config_toml_file: &str, +) -> Option { + // When the merged config fails schema validation, we surface the first concrete + // per-file error to point users at a specific file and range rather than an + // opaque merged-layer failure. + first_layer_config_error_for_entries::(layers.layers_low_to_high(), config_toml_file) + .await +} + +pub async fn first_layer_config_error_from_entries( + layers: &[ConfigLayerEntry], + config_toml_file: &str, +) -> Option { + first_layer_config_error_for_entries::(layers.iter(), config_toml_file).await +} + +async fn first_layer_config_error_for_entries<'a, T: DeserializeOwned, I>( + layers: I, + config_toml_file: &str, +) -> Option +where + I: IntoIterator, +{ + for layer in layers { + if layer.is_disabled() { + continue; + } + if let Some(contents) = layer.raw_toml() { + let source_name = format_config_layer_source(&layer.name, config_toml_file); + let Some(base_dir) = layer.raw_toml_base_dir() else { + tracing::debug!( + "Skipping raw TOML diagnostics for {source_name} because it has no base directory" + ); + continue; + }; + // Match the base directory used when the raw non-file layer was + // parsed into the runtime layer so diagnostics resolve relative + // path fields with the same semantics. + let _absolute_path_base = AbsolutePathBufGuard::new(base_dir.as_path()); + if let Some(error) = config_error_from_typed_toml_for_source::( + ConfigDiagnosticSource::DisplayName(&source_name), + contents, + ) { + return Some(error); + } + continue; + } + + let Some(path) = config_path_for_layer(layer, config_toml_file) else { + continue; + }; + let contents = match tokio::fs::read_to_string(&path).await { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + tracing::debug!("Failed to read config file {}: {err}", path.display()); + continue; + } + }; + + let Some(parent) = path.parent() else { + tracing::debug!("Config file {} has no parent directory", path.display()); + continue; + }; + let _guard = AbsolutePathBufGuard::new(parent); + if let Some(error) = config_error_from_typed_toml::(&path, &contents) { + return Some(error); + } + } + + None +} + +fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option { + match &layer.name { + ConfigLayerSource::PackagedDefaults { file } => Some(file.to_path_buf()), + ConfigLayerSource::System { file } => Some(file.to_path_buf()), + ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()), + ConfigLayerSource::Project { dot_codex_folder } => { + Some(dot_codex_folder.as_path().join(config_toml_file)) + } + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()), + ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::EnterpriseManaged { .. } + | ConfigLayerSource::SessionFlags + | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, + } +} + +pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { + let start = position_for_offset(contents, span.start); + let end_index = if span.end > span.start { + span.end - 1 + } else { + span.end + }; + let end = position_for_offset(contents, end_index); + TextRange { start, end } +} + +pub fn format_config_error(error: &ConfigError, contents: &str) -> String { + let mut output = String::new(); + let start = error.range.start; + let _ = writeln!( + output, + "{}:{}:{}: {}", + error.path.display(), + start.line, + start.column, + error.message + ); + + let line_index = start.line.saturating_sub(1); + let line = match contents.lines().nth(line_index) { + Some(line) => line.trim_end_matches('\r'), + None => return output.trim_end().to_string(), + }; + + let line_number = start.line; + let gutter = line_number.to_string().len(); + let _ = writeln!(output, "{:width$} |", "", width = gutter); + let _ = writeln!(output, "{line_number:>gutter$} | {line}"); + + let highlight_len = if error.range.end.line == error.range.start.line + && error.range.end.column >= error.range.start.column + { + error.range.end.column - error.range.start.column + 1 + } else { + 1 + }; + let spaces = " ".repeat(start.column.saturating_sub(1)); + let carets = "^".repeat(highlight_len.max(1)); + let _ = writeln!(output, "{:width$} | {spaces}{carets}", "", width = gutter); + output.trim_end().to_string() +} + +pub fn format_config_error_with_source(error: &ConfigError) -> String { + match std::fs::read_to_string(&error.path) { + Ok(contents) => format_config_error(error, &contents), + Err(_) => format_config_error(error, ""), + } +} + +fn position_for_offset(contents: &str, index: usize) -> TextPosition { + let bytes = contents.as_bytes(); + if bytes.is_empty() { + return TextPosition { line: 1, column: 1 }; + } + + let safe_index = index.min(bytes.len().saturating_sub(1)); + let column_offset = index.saturating_sub(safe_index); + let index = safe_index; + + let line_start = bytes[..index] + .iter() + .rposition(|byte| *byte == b'\n') + .map(|pos| pos + 1) + .unwrap_or(0); + let line = bytes[..line_start] + .iter() + .filter(|byte| **byte == b'\n') + .count(); + + let column = std::str::from_utf8(&bytes[line_start..=index]) + .map(|slice| slice.chars().count().saturating_sub(1)) + .unwrap_or_else(|_| index - line_start); + let column = column + column_offset; + + TextPosition { + line: line + 1, + column: column + 1, + } +} + +pub(crate) fn default_range() -> TextRange { + let position = TextPosition { line: 1, column: 1 }; + TextRange { + start: position, + end: position, + } +} + +enum TomlNode<'a> { + Item(&'a Item), + Table(&'a Table), + Value(&'a Value), +} + +fn span_for_path(contents: &str, path: &SerdePath) -> Option> { + let doc = contents.parse::>().ok()?; + let node = node_for_path(doc.as_item(), path)?; + match node { + TomlNode::Item(item) => item.span(), + TomlNode::Table(table) => table.span(), + TomlNode::Value(value) => value.span(), + } +} + +pub(crate) fn span_for_config_path( + contents: &str, + path: &SerdePath, +) -> Option> { + if is_features_table_path(path) + && let Some(span) = span_for_features_value(contents) + { + return Some(span); + } + span_for_path(contents, path) +} + +pub(crate) fn span_for_toml_key_path( + contents: &str, + path: &[String], +) -> Option> { + let doc = contents.parse::>().ok()?; + let mut node = TomlNode::Item(doc.as_item()); + for (index, segment) in path.iter().enumerate() { + if index + 1 == path.len() { + let key_span = match &node { + TomlNode::Item(item) => item + .as_table_like() + .and_then(|table| table.get_key_value(segment)) + .and_then(|(key, _)| key.span()), + TomlNode::Table(table) => { + table.get_key_value(segment).and_then(|(key, _)| key.span()) + } + TomlNode::Value(Value::InlineTable(table)) => { + table.get_key_value(segment).and_then(|(key, _)| key.span()) + } + _ => None, + }; + if key_span.is_some() { + return key_span; + } + } + + if let Some(next) = map_child(&node, segment) { + node = next; + continue; + } + + let index = segment.parse::().ok()?; + node = seq_child(&node, index)?; + } + + match node { + TomlNode::Item(item) => item.span(), + TomlNode::Table(table) => table.span(), + TomlNode::Value(value) => value.span(), + } +} + +fn is_features_table_path(path: &SerdePath) -> bool { + let mut segments = path.iter(); + matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features") + && segments.next().is_none() +} + +fn span_for_features_value(contents: &str) -> Option> { + let doc = contents.parse::>().ok()?; + let root = doc.as_item().as_table_like()?; + let features_item = root.get("features")?; + let features_table = features_item.as_table_like()?; + for (_, item) in features_table.iter() { + match item { + Item::Value(Value::Boolean(_)) => continue, + Item::Value(value) => return value.span(), + Item::Table(table) => return table.span(), + Item::ArrayOfTables(array) => return array.span(), + Item::None => continue, + } + } + None +} + +fn node_for_path<'a>(item: &'a Item, path: &SerdePath) -> Option> { + let segments: Vec<_> = path.iter().cloned().collect(); + let mut node = TomlNode::Item(item); + let mut index = 0; + while index < segments.len() { + match &segments[index] { + SerdeSegment::Map { key } | SerdeSegment::Enum { variant: key } => { + if let Some(next) = map_child(&node, key) { + node = next; + index += 1; + continue; + } + + if index + 1 < segments.len() { + index += 1; + continue; + } + return None; + } + SerdeSegment::Seq { index: seq_index } => { + node = seq_child(&node, *seq_index)?; + index += 1; + } + SerdeSegment::Unknown => return None, + } + } + Some(node) +} + +fn map_child<'a>(node: &TomlNode<'a>, key: &str) -> Option> { + match node { + TomlNode::Item(item) => { + let table = item.as_table_like()?; + table.get(key).map(TomlNode::Item) + } + TomlNode::Table(table) => table.get(key).map(TomlNode::Item), + TomlNode::Value(Value::InlineTable(table)) => table.get(key).map(TomlNode::Value), + _ => None, + } +} + +fn seq_child<'a>(node: &TomlNode<'a>, index: usize) -> Option> { + match node { + TomlNode::Item(Item::Value(Value::Array(array))) => array.get(index).map(TomlNode::Value), + TomlNode::Item(Item::ArrayOfTables(array)) => array.get(index).map(TomlNode::Table), + TomlNode::Value(Value::Array(array)) => array.get(index).map(TomlNode::Value), + _ => None, + } +} diff --git a/codex-rs/config/src/filesystem_constraints.rs b/codex-rs/config/src/filesystem_constraints.rs new file mode 100644 index 0000000000000000000000000000000000000000..0eebd85565a0154bcd2f5c4c0a5cc4450145a31c --- /dev/null +++ b/codex-rs/config/src/filesystem_constraints.rs @@ -0,0 +1,62 @@ +//! Convert resolved filesystem denials into portable policy entries. + +use crate::FilesystemConstraints; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_path_uri::LegacyAppPathStringError; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; +use std::io; + +impl FilesystemConstraints { + /// Adds denials without changing existing entries or other policy settings. + /// `convention` must match the executor facts used to resolve these requirements. + /// Literal paths become URIs; glob spellings and entry order are preserved. + /// Invalid denials return an error before the policy is changed. + pub fn apply_to_policy( + &self, + policy: &mut FileSystemSandboxPolicy, + convention: PathConvention, + ) -> io::Result<()> { + let entries = self + .deny_read + .iter() + .map(|deny_read| { + PathUri::validate_config_path_text(deny_read.as_str(), convention)?; + let path = if deny_read.contains_glob() { + FileSystemPath::GlobPattern { + pattern: deny_read.as_str().to_string(), + } + } else { + let mut path = LegacyAppPathString::from_string(deny_read.as_str()) + .to_path_uri(convention)?; + path.validate_config_path(convention)?; + // Match Core's existing URI spelling for UNC share roots. + if path.to_url().host_str().is_some() { + path = path.join(".")?; + } + path.into() + }; + Ok(FileSystemSandboxEntry::new( + path, + FileSystemAccessMode::Deny, + )) + }) + .collect::, LegacyAppPathStringError>>() + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid permissions.filesystem.deny_read path: {error}"), + ) + })?; + for entry in entries { + if !policy.entries.contains(&entry) { + policy.entries.push(entry); + } + } + Ok(()) + } +} diff --git a/codex-rs/config/src/fingerprint.rs b/codex-rs/config/src/fingerprint.rs new file mode 100644 index 0000000000000000000000000000000000000000..b4122b1da5f53a7f040108b95e0a2b32953641c5 --- /dev/null +++ b/codex-rs/config/src/fingerprint.rs @@ -0,0 +1,84 @@ +use crate::ConfigLayerMetadata; +use crate::merge::is_structured_feature_path; +use serde_json::Value as JsonValue; +use sha2::Digest; +use sha2::Sha256; +use std::collections::HashMap; +use toml::Value as TomlValue; + +pub(super) fn record_origins( + value: &TomlValue, + meta: &ConfigLayerMetadata, + path: &mut Vec, + origins: &mut HashMap, + include: &impl Fn(&[String]) -> bool, +) { + match value { + TomlValue::Table(table) => { + for (key, val) in table { + path.push(key.clone()); + record_origins(val, meta, path, origins, include); + path.pop(); + } + } + TomlValue::Array(items) => { + for (idx, item) in (0_i32..).zip(items.iter()) { + path.push(idx.to_string()); + record_origins(item, meta, path, origins, include); + path.pop(); + } + } + _ => { + if !path.is_empty() { + if !include(path) { + return; + } + if matches!(value, TomlValue::Boolean(_)) && is_structured_feature_path(path) { + if path + .last() + .is_some_and(|feature| feature == "network_proxy") + { + origins.insert(path.join("."), meta.clone()); + } + path.push("enabled".to_string()); + origins.insert(path.join("."), meta.clone()); + path.pop(); + return; + } + origins.insert(path.join("."), meta.clone()); + } + } + } +} + +pub fn version_for_toml(value: &TomlValue) -> String { + let json = serde_json::to_value(value).unwrap_or(JsonValue::Null); + let canonical = canonical_json(&json); + let serialized = serde_json::to_vec(&canonical).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(serialized); + let hash = hasher.finalize(); + let hex = hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("sha256:{hex}") +} + +fn canonical_json(value: &JsonValue) -> JsonValue { + match value { + JsonValue::Object(map) => { + let mut sorted = serde_json::Map::new(); + let mut keys = map.keys().cloned().collect::>(); + keys.sort(); + for key in keys { + if let Some(val) = map.get(&key) { + sorted.insert(key, canonical_json(val)); + } + } + JsonValue::Object(sorted) + } + JsonValue::Array(items) => JsonValue::Array(items.iter().map(canonical_json).collect()), + other => other.clone(), + } +} diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs new file mode 100644 index 0000000000000000000000000000000000000000..7575fa5fcb1042cc380812308f62b8378540ad2a --- /dev/null +++ b/codex-rs/config/src/hook_config.rs @@ -0,0 +1,256 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; + +use codex_protocol::protocol::HookEventName; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct HooksFile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub hooks: HookEventsToml, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HooksToml { + #[serde(flatten)] + pub events: HookEventsToml, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub state: BTreeMap, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HookStateToml { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted_hash: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HookEventsToml { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest", default)] + pub permission_request: Vec, + #[serde(rename = "PostToolUse", default)] + pub post_tool_use: Vec, + #[serde(rename = "PreCompact", default)] + pub pre_compact: Vec, + #[serde(rename = "PostCompact", default)] + pub post_compact: Vec, + #[serde(rename = "SessionStart", default)] + pub session_start: Vec, + #[serde(rename = "SessionEnd", default)] + pub session_end: Vec, + #[serde(rename = "UserPromptSubmit", default)] + pub user_prompt_submit: Vec, + #[serde(rename = "SubagentStart", default)] + pub subagent_start: Vec, + #[serde(rename = "SubagentStop", default)] + pub subagent_stop: Vec, + #[serde(rename = "Stop", default)] + pub stop: Vec, + #[serde(rename = "Interrupt", default)] + pub interrupt: Vec, +} + +impl HookEventsToml { + pub fn is_empty(&self) -> bool { + let Self { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + session_end, + user_prompt_submit, + subagent_start, + subagent_stop, + stop, + interrupt, + } = self; + pre_tool_use.is_empty() + && permission_request.is_empty() + && post_tool_use.is_empty() + && pre_compact.is_empty() + && post_compact.is_empty() + && session_start.is_empty() + && session_end.is_empty() + && user_prompt_submit.is_empty() + && subagent_start.is_empty() + && subagent_stop.is_empty() + && stop.is_empty() + && interrupt.is_empty() + } + + pub fn handler_count(&self) -> usize { + let Self { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + session_end, + user_prompt_submit, + subagent_start, + subagent_stop, + stop, + interrupt, + } = self; + [ + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + session_end, + user_prompt_submit, + subagent_start, + subagent_stop, + stop, + interrupt, + ] + .into_iter() + .flatten() + .map(|group| group.hooks.len()) + .sum() + } + + pub fn into_matcher_groups(mut self) -> [(HookEventName, Vec); 12] { + self.matcher_groups_mut() + .map(|(event, groups)| (event, std::mem::take(groups))) + } + + pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec); 12] { + use HookEventName as Event; + + [ + (Event::PreToolUse, &mut self.pre_tool_use), + (Event::PermissionRequest, &mut self.permission_request), + (Event::PostToolUse, &mut self.post_tool_use), + (Event::PreCompact, &mut self.pre_compact), + (Event::PostCompact, &mut self.post_compact), + (Event::SessionStart, &mut self.session_start), + (Event::SessionEnd, &mut self.session_end), + (Event::UserPromptSubmit, &mut self.user_prompt_submit), + (Event::SubagentStart, &mut self.subagent_start), + (Event::SubagentStop, &mut self.subagent_stop), + (Event::Stop, &mut self.stop), + (Event::Interrupt, &mut self.interrupt), + ] + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MatcherGroup { + #[serde(default)] + pub matcher: Option, + #[serde(default)] + pub hooks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type")] +pub enum HookHandlerConfig { + #[serde(rename = "command")] + Command { + command: String, + #[serde(default, rename = "commandWindows", alias = "command_windows")] + command_windows: Option, + #[serde(default, rename = "timeout")] + timeout_sec: Option, + #[serde(default)] + r#async: bool, + #[serde(default, rename = "statusMessage")] + status_message: Option, + /// Approximate token threshold for spilling this hook's `additionalContext` to disk. + /// Unset uses 2,500 tokens; `0` disables spilling for this hook. The threshold is + /// evaluated against the original context; a spilled preview also includes recovery + /// metadata. + #[serde( + default, + rename = "additionalContextLimit", + skip_serializing_if = "Option::is_none" + )] + additional_context_limit: Option, + }, + #[serde(rename = "mcp_tool")] + McpTool { + server: String, + tool: String, + #[serde(default, deserialize_with = "deserialize_mcp_tool_input")] + input: serde_json::Map, + #[serde(default, rename = "timeout")] + timeout_sec: Option, + #[serde(default, rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + Agent {}, +} + +// Reject values such as null that cannot be represented in TOML for trust hashing. +fn deserialize_mcp_tool_input<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let input = serde_json::Map::deserialize(deserializer)?; + toml::Value::try_from(&input).map_err(|error| { + serde::de::Error::custom(format!( + "MCP hook input must be representable as TOML: {error}" + )) + })?; + Ok(input) +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedHooksRequirementsToml { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(flatten)] + pub hooks: HookEventsToml, +} + +impl ManagedHooksRequirementsToml { + pub fn is_empty(&self) -> bool { + let Self { + managed_dir, + windows_managed_dir, + hooks, + } = self; + managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty() + } + + pub fn handler_count(&self) -> usize { + self.hooks.handler_count() + } + + pub fn managed_dir_for_current_platform(&self) -> Option<&Path> { + #[cfg(windows)] + { + self.windows_managed_dir.as_deref() + } + + #[cfg(not(windows))] + { + self.managed_dir.as_deref() + } + } +} + +#[cfg(test)] +#[path = "hooks_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/in_app_browser_requirements.rs b/codex-rs/config/src/in_app_browser_requirements.rs new file mode 100644 index 0000000000000000000000000000000000000000..54442f88f679ef9f2fa7f138efaee55f673b8614 --- /dev/null +++ b/codex-rs/config/src/in_app_browser_requirements.rs @@ -0,0 +1,14 @@ +use serde::Deserialize; + +/// Managed requirements for the interactive in-app browser, not agent Browser Use. +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct InAppBrowserRequirementsToml { + /// Whether external browser settings may be imported. Only `Some(false)` + /// denies import; omission is effectively `true`. Keep omission unset while + /// composing managed layers so it cannot replace an explicit requirement. + pub allow_external_browser_settings_import: Option, +} + +#[cfg(test)] +#[path = "in_app_browser_requirements_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/key_aliases.rs b/codex-rs/config/src/key_aliases.rs new file mode 100644 index 0000000000000000000000000000000000000000..c0f2dea391d4e22c9f381aa17be4e9153be3846e --- /dev/null +++ b/codex-rs/config/src/key_aliases.rs @@ -0,0 +1,59 @@ +use toml::Value as TomlValue; +use toml::map::Map as TomlMap; + +#[derive(Debug, Clone, Copy)] +struct ConfigKeyAlias { + table_path: &'static [&'static str], + legacy_key: &'static str, + canonical_key: &'static str, +} + +const CONFIG_KEY_ALIASES: &[ConfigKeyAlias] = &[ + ConfigKeyAlias { + table_path: &["memories"], + legacy_key: "no_memories_if_mcp_or_web_search", + canonical_key: "disable_on_external_context", + }, + ConfigKeyAlias { + table_path: &["agents"], + legacy_key: "max_threads", + canonical_key: "max_concurrent_threads_per_session", + }, +]; + +pub(crate) fn normalize_key_aliases(path: &[String], table: &mut TomlMap) { + for alias in CONFIG_KEY_ALIASES { + if path + .iter() + .map(String::as_str) + .eq(alias.table_path.iter().copied()) + && let Some(value) = table.remove(alias.legacy_key) + { + table + .entry(alias.canonical_key.to_string()) + .or_insert(value); + } + } +} + +pub(crate) fn normalized_with_key_aliases(value: &TomlValue, path: &[String]) -> TomlValue { + match value { + TomlValue::Table(table) => { + let mut normalized = TomlMap::new(); + for (key, child) in table { + let mut child_path = path.to_vec(); + child_path.push(key.clone()); + normalized.insert(key.clone(), normalized_with_key_aliases(child, &child_path)); + } + normalize_key_aliases(path, &mut normalized); + TomlValue::Table(normalized) + } + TomlValue::Array(items) => TomlValue::Array( + items + .iter() + .map(|item| normalized_with_key_aliases(item, path)) + .collect(), + ), + _ => value.clone(), + } +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..949e883837fd6ead9c6f7833040cb7584de4c00a --- /dev/null +++ b/codex-rs/config/src/lib.rs @@ -0,0 +1,218 @@ +mod application_requirements; +mod auth_policy; +mod browser_computer_use_requirements; +mod browser_use; +mod cloud_config_bundle; +mod cloud_config_layers; +#[cfg(target_os = "macos")] +mod codex_home_symlink; +mod computer_use; +mod config_layer_source; +mod config_requirements; +pub mod config_toml; +mod constraint; +mod diagnostics; +mod filesystem_constraints; +mod fingerprint; +mod hook_config; +mod host_name; +mod in_app_browser_requirements; +mod key_aliases; +pub mod loader; +mod marketplace_edit; +mod mcp_edit; +mod mcp_ema; +mod mcp_requirements; +mod mcp_types; +mod merge; +mod model_provider_requirements; +mod overrides; +mod path_context; +pub mod permissions_toml; +mod plugin_edit; +pub mod profile_toml; +mod project_root_markers; +mod requirements_exec_policy; +mod requirements_layers; +pub mod schema; +mod shell_environment_policy; +mod skills_config; +mod state; +mod strict_config; +pub mod test_support; +mod thread_config; +mod tui_keymap; +pub mod types; + +pub const CONFIG_TOML_FILE: &str = "config.toml"; + +pub use application_requirements::ApplicationNetworkRequirementsToml; +pub use application_requirements::ApplicationRequirementsToml; +pub use auth_policy::ManagedAuthPolicy; +pub use browser_computer_use_requirements::AllowDenyRequirementToml; +pub use browser_computer_use_requirements::BrowserUseAccessApprovalLifetimeToml; +pub use browser_computer_use_requirements::BrowserUseOriginPolicyToml; +pub use browser_computer_use_requirements::BrowserUseRequirementsToml; +pub use browser_computer_use_requirements::ComputerUseMacosRequirementsToml; +pub use browser_computer_use_requirements::ComputerUseRequirementsToml; +pub use browser_computer_use_requirements::ComputerUseWindowsExeRequirementToml; +pub use browser_computer_use_requirements::ComputerUseWindowsRequirementsToml; +pub use browser_use::BrowserUseConfigToml; +pub use browser_use::BrowserUseOriginPolicyConfigToml; +pub use cloud_config_bundle::CloudConfigBundle; +pub use cloud_config_bundle::CloudConfigBundleLayers; +pub use cloud_config_bundle::CloudConfigBundleLoadError; +pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode; +pub use cloud_config_bundle::CloudConfigBundleLoader; +pub use cloud_config_bundle::CloudConfigTomlBundle; +pub use cloud_config_bundle::CloudRequirementsFragment; +pub use cloud_config_bundle::CloudRequirementsTomlBundle; +pub use cloud_config_layers::CloudConfigFragment; +pub use cloud_config_layers::CloudConfigFragmentSource; +pub use cloud_config_layers::CloudConfigLayerError; +pub use cloud_config_layers::cloud_config_layers_from_fragments; +pub use codex_execpolicy::RequirementsExecPolicy; +#[cfg(target_os = "macos")] +pub use codex_home_symlink::allowed_symlinked_codex_home; +pub use codex_protocol::config_types::ProfileV2Name; +pub use codex_protocol::config_types::ProfileV2NameParseError; +pub use codex_protocol::config_types::ToolExposureSurface; +pub use codex_protocol::mcp_policy::McpServerCommandMatcher; +pub use codex_protocol::mcp_policy::McpServerIdentity; +pub use codex_protocol::mcp_policy::McpServerRequirement; +pub use codex_protocol::mcp_policy::McpServerValueMatcher; +pub use codex_protocol::mcp_policy::PluginMcpRequirements as PluginRequirementsToml; +pub use codex_utils_absolute_path::AbsolutePathBuf; +pub use codex_utils_absolute_path::AbsolutePathBufGuard; +pub use computer_use::ComputerUseConfigToml; +pub use computer_use::ComputerUseMacosConfigToml; +pub use computer_use::ComputerUseWindowsConfigToml; +pub use computer_use::ComputerUseWindowsExeConfigToml; +pub use config_layer_source::ConfigLayer; +pub use config_layer_source::ConfigLayerMetadata; +pub use config_layer_source::ConfigLayerSource; +pub use config_layer_source::format_config_layer_source; +pub use config_requirements::AppRequirementToml; +pub use config_requirements::AppToolRequirementToml; +pub use config_requirements::AppToolResultSourceFormat; +pub use config_requirements::AppToolResultSourceRequirementToml; +pub use config_requirements::AppToolsRequirementsToml; +pub use config_requirements::AppsRequirementsToml; +pub use config_requirements::AutoReviewRequirementsToml; +pub use config_requirements::ConfigRequirements; +pub use config_requirements::ConfigRequirementsToml; +pub use config_requirements::ConfigRequirementsWithSources; +pub use config_requirements::ConstrainedWithSource; +pub use config_requirements::FeatureRequirementsToml; +pub use config_requirements::FilesystemConstraints; +pub use config_requirements::FilesystemDenyReadPattern; +pub use config_requirements::MarketplaceAllowedSourceKind; +pub use config_requirements::MarketplaceAllowedSourceToml; +pub use config_requirements::MarketplaceRequirementsToml; +pub use config_requirements::ModelsRequirementsToml; +pub use config_requirements::NetworkConstraints; +pub use config_requirements::NetworkDomainPermissionToml; +pub use config_requirements::NetworkDomainPermissionsToml; +pub use config_requirements::NetworkHeaderInjectionToml; +pub use config_requirements::NetworkRequirementsToml; +pub use config_requirements::NetworkUnixSocketPermissionToml; +pub use config_requirements::NetworkUnixSocketPermissionsToml; +pub use config_requirements::NewThreadModelDefaultsToml; +pub use config_requirements::RemoteSandboxConfigToml; +pub use config_requirements::RequirementSource; +pub use config_requirements::ResidencyRequirement; +pub use config_requirements::SandboxModeRequirement; +pub use config_requirements::Sourced; +pub use config_requirements::WebSearchModeRequirement; +pub use config_requirements::WindowsRequirementsToml; +pub use config_requirements::sandbox_mode_requirement_for_permission_profile; +pub use constraint::Constrained; +pub use constraint::ConstraintError; +pub use constraint::ConstraintResult; +pub use diagnostics::ConfigError; +pub use diagnostics::ConfigLoadError; +pub use diagnostics::TextPosition; +pub use diagnostics::TextRange; +pub use diagnostics::config_error_from_toml; +pub use diagnostics::config_error_from_typed_toml; +pub use diagnostics::first_layer_config_error; +pub use diagnostics::first_layer_config_error_from_entries; +pub use diagnostics::format_config_error; +pub use diagnostics::format_config_error_with_source; +pub use diagnostics::io_error_from_config_error; +pub use fingerprint::version_for_toml; +pub use hook_config::HookEventsToml; +pub use hook_config::HookHandlerConfig; +pub use hook_config::HookStateToml; +pub use hook_config::HooksFile; +pub use hook_config::HooksToml; +pub use hook_config::ManagedHooksRequirementsToml; +pub use hook_config::MatcherGroup; +pub use host_name::host_name; +pub use host_name::os_host_name; +pub use in_app_browser_requirements::InAppBrowserRequirementsToml; +pub use marketplace_edit::MarketplaceConfigUpdate; +pub use marketplace_edit::RemoveMarketplaceConfigOutcome; +pub use marketplace_edit::record_user_marketplace; +pub use marketplace_edit::remove_user_marketplace; +pub use marketplace_edit::remove_user_marketplace_config; +pub use mcp_edit::load_global_mcp_servers; +pub use mcp_ema::McpEmaRegistration; +pub use mcp_ema::McpEnterpriseManagedAuthConfig; +pub use mcp_ema::McpServerIdpOAuthConfig; +pub use mcp_types::AppToolApproval; +pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; +pub use mcp_types::McpServerAuth; +pub use mcp_types::McpServerConfig; +pub use mcp_types::McpServerDisabledReason; +pub use mcp_types::McpServerEnvVar; +pub use mcp_types::McpServerOAuthConfig; +pub use mcp_types::McpServerToolConfig; +pub use mcp_types::McpServerTransportConfig; +pub use mcp_types::RawMcpServerConfig; +pub use merge::ShellEnvironmentPolicyFilterRepresentation; +pub use merge::is_structured_feature_path; +pub use merge::merge_toml_values; +pub use merge::shell_environment_filter_entry; +pub use overrides::build_cli_overrides_layer; +pub use path_context::ConfigPathContext; +pub use plugin_edit::PluginConfigEdit; +pub use plugin_edit::apply_user_plugin_config_edits; +pub use plugin_edit::clear_user_plugin; +pub use plugin_edit::set_user_plugin_enabled; +pub use project_root_markers::default_project_root_markers; +pub use project_root_markers::project_root_markers_from_config; +pub use requirements_exec_policy::RequirementsExecPolicyDecisionToml; +pub use requirements_exec_policy::RequirementsExecPolicyParseError; +pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml; +pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml; +pub use requirements_exec_policy::RequirementsExecPolicyToml; +pub use requirements_layers::RequirementsLayerEntry; +pub use requirements_layers::compose_requirements; +pub use requirements_layers::compose_requirements_for_hostname; +pub use shell_environment_policy::validate_shell_environment_policy_filter_config; +pub use skills_config::BundledSkillsConfig; +pub use skills_config::SkillConfig; +pub use skills_config::SkillConfigRule; +pub use skills_config::SkillConfigRuleSelector; +pub use skills_config::SkillConfigRules; +pub use skills_config::SkillsConfig; +pub use skills_config::bundled_skills_enabled_from_stack; +pub use skills_config::skill_config_rules_from_stack; +pub use state::ConfigLayerEntry; +pub use state::ConfigLayerStack; +pub use state::ConfigLoadOptions; +pub use state::LoaderOverrides; +pub use strict_config::config_error_from_ignored_toml_fields; +pub use thread_config::NoopThreadConfigLoader; +pub use thread_config::RemoteThreadConfigLoader; +pub use thread_config::SessionThreadConfig; +pub use thread_config::StaticThreadConfigLoader; +pub use thread_config::ThreadConfigContext; +pub use thread_config::ThreadConfigLoadError; +pub use thread_config::ThreadConfigLoadErrorCode; +pub use thread_config::ThreadConfigLoader; +pub use thread_config::ThreadConfigLoaderFuture; +pub use thread_config::ThreadConfigSource; +pub use thread_config::UserThreadConfig; +pub use toml::Value as TomlValue; diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ed4352cf727e540718d780e74e6cd698da77ea9 --- /dev/null +++ b/codex-rs/config/src/mcp_edit.rs @@ -0,0 +1,50 @@ +use std::collections::BTreeMap; +use std::io::ErrorKind; +use std::path::Path; + +use toml::Value as TomlValue; + +use crate::CONFIG_TOML_FILE; +use crate::McpServerConfig; + +pub async fn load_global_mcp_servers( + codex_home: &Path, +) -> std::io::Result> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let raw = match tokio::fs::read_to_string(&config_path).await { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(BTreeMap::new()), + Err(err) => return Err(err), + }; + let parsed = toml::from_str::(&raw) + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?; + let Some(servers_value) = parsed.get("mcp_servers") else { + return Ok(BTreeMap::new()); + }; + + ensure_no_inline_bearer_tokens(servers_value)?; + + servers_value + .clone() + .try_into() + .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err)) +} + +fn ensure_no_inline_bearer_tokens(value: &TomlValue) -> std::io::Result<()> { + let Some(servers_table) = value.as_table() else { + return Ok(()); + }; + + for (server_name, server_value) in servers_table { + if let Some(server_table) = server_value.as_table() + && server_table.contains_key("bearer_token") + { + let message = format!( + "mcp_servers.{server_name} uses unsupported `bearer_token`; set `bearer_token_env_var`." + ); + return Err(std::io::Error::new(ErrorKind::InvalidData, message)); + } + } + + Ok(()) +} diff --git a/codex-rs/config/src/mcp_ema_tests.rs b/codex-rs/config/src/mcp_ema_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..52114977258248c9dc807a02411f9c6cfa999d56 --- /dev/null +++ b/codex-rs/config/src/mcp_ema_tests.rs @@ -0,0 +1,309 @@ +//! Enterprise registration provenance checks. + +use super::*; +use crate::AbsolutePathBuf; +use crate::ConfigLayerEntry; +use crate::ConfigRequirements; +use crate::ConfigRequirementsToml; +use pretty_assertions::assert_eq; + +fn stack(layers: Vec<(ConfigLayerSource, &str)>) -> ConfigLayerStack { + ConfigLayerStack::new( + layers + .into_iter() + .map(|(source, value)| ConfigLayerEntry::new(source, toml::from_str(value).unwrap())) + .collect(), + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .unwrap() +} + +fn local_sources(name: &str) -> (ConfigLayerSource, ConfigLayerSource, ConfigLayerSource) { + let path = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join(name)).unwrap(); + ( + ConfigLayerSource::System { file: path.clone() }, + ConfigLayerSource::User { + file: path.clone(), + profile: None, + }, + ConfigLayerSource::Project { + dot_codex_folder: path, + }, + ) +} + +fn validate_effective_ema(stack: &ConfigLayerStack) -> std::io::Result<()> { + let servers = stack + .effective_config() + .get("mcp_servers") + .cloned() + .unwrap_or_else(|| toml::Value::Table(Default::default())) + .try_into::>() + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + validate_ema_auth_sources(stack, &servers) +} + +fn valid_ema_layers(layers: Vec<(ConfigLayerSource, &str)>) -> bool { + validate_effective_ema(&stack(layers)).is_ok() +} + +#[test] +fn ema_profiles_and_auth_modes_preserve_non_project_authority() { + let (system, user, project) = local_sources("ema-config"); + let trusted = "[mcp_enterprise_managed_auth.idp]\nissuer = 'https://idp.example'\nclient_id = 'enterprise'"; + let replacement = + "[mcp_enterprise_managed_auth.idp]\nissuer = 'https://other.example'\nclient_id = 'other'"; + let expected = McpEnterpriseManagedAuthConfig { + idp: McpServerIdpOAuthConfig { + issuer: "https://idp.example".into(), + client_id: "enterprise".into(), + }, + }; + for (layers, selected) in [ + ( + vec![ + (system.clone(), trusted), + (user.clone(), replacement), + (project.clone(), replacement), + ], + Some(expected.clone()), + ), + ( + vec![(user.clone(), trusted), (project.clone(), replacement)], + Some(expected.clone()), + ), + (vec![(project, trusted)], None), + ( + vec![ + (system, trusted), + ( + user, + "[mcp_enterprise_managed_auth.idp]\nclient_id = 'partial'", + ), + ], + Some(expected), + ), + ] { + assert_eq!( + McpEnterpriseManagedAuthConfig::from_config_layers( + &stack(layers), + /*fallback*/ None + ) + .unwrap(), + selected + ); + } + let incomplete = stack(vec![( + ConfigLayerSource::SessionFlags, + "[mcp_enterprise_managed_auth.idp]\nclient_id = 'partial'", + )]); + assert!( + McpEnterpriseManagedAuthConfig::from_config_layers(&incomplete, /*fallback*/ None).is_err() + ); + + let server: McpServerConfig = toml::from_str("url='https://resource.example'\nauth='ema_auth'\n[oauth.idp]\nissuer='https://other.example'\nclient_id='other'").unwrap(); + assert_eq!(server.oauth_idp(), None); +} + +#[test] +fn ema_registrations_require_one_atomic_non_project_source() { + let (system, user, project) = local_sources("ema-registration-sources"); + for (section, authorization, protected_changes) in [ + ( + "mcp_servers.enterprise", + r#" + auth='ema_auth' + oauth_resource='https://resource.example' + oauth.client_id='resource-client' + oauth.authorization_server_issuer='https://as.example' + "#, + &[ + "oauth_resource='https://other.example'", + "oauth.client_id='other-client'", + "oauth.authorization_server_issuer='https://other-as.example'", + ] as &[&str], + ), + ( + "plugins.\"sample@test\".mcp_servers.enterprise.ema_auth", + r#" + resource='https://resource.example' + client_id='resource-client' + authorization_server_issuer='https://as.example' + "#, + &[ + "resource='https://other.example'", + "client_id='other-client'", + "authorization_server_issuer='https://other-as.example'", + ], + ), + ] { + let registration = format!( + "[{section}]\nurl='https://resource.example/mcp'\nscopes=['tools']\n{authorization}" + ); + for (source, allowed) in [ + (system.clone(), true), + (user.clone(), true), + (project.clone(), false), + ] { + assert_eq!( + valid_ema_layers(vec![(source, registration.as_str())]), + allowed, + "{section}" + ); + } + for change in protected_changes.iter().copied().chain([ + "url='https://other.example/mcp'", + "url='https://resource.example/mcp/other'", + "scopes=['admin']", + ]) { + let overlay = format!("[{section}]\n{change}"); + assert!( + !valid_ema_layers(vec![ + (system.clone(), ®istration), + (project.clone(), &overlay) + ]), + "{section}: {change}" + ); + } + let higher = registration.replace("tools", "managed-tools"); + assert!( + !valid_ema_layers(vec![ + (system.clone(), ®istration), + (user.clone(), &higher), + (project.clone(), ®istration) + ]), + "project rollback: {section}" + ); + let policy_section = section.strip_suffix(".ema_auth").unwrap_or(section); + let policy = format!( + "[{policy_section}]\nenabled=false\n[{policy_section}.tools.read]\napproval_mode='prompt'" + ); + assert!(valid_ema_layers(vec![ + (system.clone(), ®istration), + (project.clone(), &policy) + ])); + if section.ends_with(".ema_auth") { + for required in [ + "url='https://resource.example/mcp'\n", + "resource='https://resource.example'\n", + ] { + assert!( + !valid_ema_layers(vec![(system.clone(), ®istration.replace(required, ""))]), + "missing {required}" + ); + } + } else { + assert!(valid_ema_layers(vec![ + (system.clone(), ®istration), + (project.clone(), "[mcp_servers.enterprise]\nauth='ema_auth'") + ])); + } + } +} + +#[test] +fn project_cannot_reenable_trusted_disabled_ema_registration() { + let (system, user, project) = local_sources("ema-disabled-source"); + let trusted = "[mcp_servers.enterprise]\nurl='https://resource.example/mcp'\nauth='ema_auth'\nenabled=false"; + let project_enable = "[mcp_servers.enterprise]\nenabled=true"; + assert!(!valid_ema_layers(vec![ + (system.clone(), trusted), + (project.clone(), project_enable) + ])); + assert!(valid_ema_layers(vec![ + (system.clone(), trusted), + (user, project_enable) + ])); + let enabled = trusted.replace("enabled=false", "enabled=true"); + assert!(valid_ema_layers(vec![ + (system, &enabled), + (project, "[mcp_servers.enterprise]\nenabled=false") + ])); +} + +#[test] +fn project_cannot_reenable_trusted_disabled_ema_plugin() { + let (system, _, project) = local_sources("ema-disabled-plugin"); + let trusted = r#" + [plugins."sample@test".mcp_servers.enterprise] + enabled=false + [plugins."sample@test".mcp_servers.enterprise.ema_auth] + url='https://resource.example/mcp' + client_id='resource-client' + authorization_server_issuer='https://as.example' + resource='https://resource.example' + "#; + let project_enable = "[plugins.\"sample@test\".mcp_servers.enterprise]\nenabled=true"; + assert!(!valid_ema_layers(vec![ + (system.clone(), trusted), + (project.clone(), project_enable) + ])); + assert!(valid_ema_layers(vec![ + (system, trusted), + ( + project, + "[plugins.\"sample@test\".mcp_servers.enterprise]\nenabled=false" + ) + ])); +} + +#[test] +fn non_project_auth_changes_and_ordinary_oauth_remain_allowed() { + let (system, user, project) = local_sources("ema-auth-downgrade"); + for (base_auth, source) in [("ema_auth", user), ("oauth", project)] { + let base = format!( + "[mcp_servers.enterprise]\nurl='https://resource.example/mcp'\nauth='{base_auth}'" + ); + assert!( + validate_effective_ema(&stack(vec![ + (system.clone(), &base), + ( + source, + "[mcp_servers.enterprise]\nauth='oauth'\nenabled=false" + ), + ])) + .is_ok() + ); + } +} + +#[test] +fn xaa_opt_in_requires_a_non_project_source() { + let (_, user, project) = local_sources("xaa-sources"); + let session = ConfigLayerSource::SessionFlags; + let enabled = "[features]\nuse_xaa=true"; + + for (source, allowed) in [(user, true), (session, true), (project.clone(), false)] { + assert_eq!( + validate_xaa_opt_in_source(&stack(vec![(source, enabled)]), /*xaa_enabled*/ true,) + .is_ok(), + allowed + ); + } + assert!( + validate_xaa_opt_in_source(&stack(vec![(project, enabled)]), /*xaa_enabled*/ false,) + .is_ok() + ); +} + +#[test] +fn ema_rejects_alternate_credentials_and_executor_custody() { + for extra in [ + "bearer_token_env_var='TOKEN'", + "http_headers.Authorization='secret'", + "http_headers.Accept='application/json'", + "env_http_headers.X-Key='TOKEN'", + "http_headers_helper='get-headers'", + "environment_id='remote'", + ] { + let server: McpServerConfig = toml::from_str(&format!( + "url='https://resource.example'\nauth='ema_auth'\n{extra}" + )) + .unwrap(); + assert!(server.validate_ema_auth_transport().is_err(), "{extra}"); + } + let server: McpServerConfig = + toml::from_str("url='https://resource.example'\nauth='ema_auth'\nhttp_headers={}").unwrap(); + assert_eq!(server.validate_ema_auth_transport(), Ok(())); +} diff --git a/codex-rs/config/src/mcp_requirements_tests.rs b/codex-rs/config/src/mcp_requirements_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..8ce0c546e2dc1c08673e49835c5472101b509114 --- /dev/null +++ b/codex-rs/config/src/mcp_requirements_tests.rs @@ -0,0 +1,225 @@ +use super::*; +use crate::McpServerCommandMatcher; +use crate::mcp_types::McpServerConfig; +use pretty_assertions::assert_eq; +use std::collections::HashMap; + +fn stdio_server(command: &str, args: &[&str]) -> McpServerConfig { + McpServerConfig { + auth: Default::default(), + transport: McpServerTransportConfig::Stdio { + command: command.to_string(), + args: args.iter().map(ToString::to_string).collect(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + omit_tools_from: None, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + } +} + +#[test] +fn command_matcher_matches_exact_positional_arguments() { + let requirement = McpServerRequirement::Command(McpServerCommandMatcher { + executable: "company-cli".to_string(), + args: vec![ + McpServerValueMatcher::Exact { + value: "mcp".to_string(), + }, + McpServerValueMatcher::Regex { + expression: r"https://[a-z]+\.example\.com".to_string(), + }, + ], + }); + + assert!( + stdio_server("company-cli", &["mcp", "https://pricing.example.com"]) + .matches_requirement(&requirement) + ); + assert!( + !stdio_server("company-cli", &["https://pricing.example.com", "mcp"]) + .matches_requirement(&requirement) + ); + assert!( + !stdio_server( + "company-cli", + &["mcp", "https://pricing.example.com", "--verbose"] + ) + .matches_requirement(&requirement) + ); + assert!( + !stdio_server( + "/usr/local/bin/company-cli", + &["mcp", "https://pricing.example.com"] + ) + .matches_requirement(&requirement) + ); +} + +#[test] +fn regex_matcher_requires_a_full_value_match() { + let matcher = McpServerValueMatcher::Regex { + expression: "mcp".to_string(), + }; + + assert!(matches_value(&matcher, "mcp")); + assert!(!matches_value(&matcher, "mcp-proxy")); + assert!(!matches_value(&matcher, "prefix-mcp")); +} + +#[test] +fn regex_matcher_allows_a_later_alternative_to_match_the_full_value() { + let matcher = McpServerValueMatcher::Regex { + expression: r"https://api\.example\.com|https://api\.example\.com/mcp".to_string(), + }; + + assert!(matches_value(&matcher, "https://api.example.com/mcp")); +} + +#[test] +fn regex_matcher_validation_rejects_expression_that_cannot_be_wrapped() { + let matcher = McpServerValueMatcher::Regex { + expression: "(?x)mcp # trailing comment".to_string(), + }; + + let err = validate_value_matcher(&matcher) + .expect_err("expression should not be valid for full-value matching"); + assert!( + err.contains("cannot be used for full-value matching"), + "{err}" + ); +} + +#[test] +fn legacy_command_identity_keeps_ignoring_arguments() { + let requirement: McpServerRequirement = toml::from_str( + r#" +[identity] +command = "company-cli" +"#, + ) + .expect("legacy command identity"); + + assert!( + stdio_server("company-cli", &["any", "arguments", "remain", "allowed"]) + .matches_requirement(&requirement) + ); + assert!(!stdio_server("different-cli", &[]).matches_requirement(&requirement)); +} + +#[test] +fn requirement_deserializes_command_and_url_matcher_shapes() { + let command: McpServerRequirement = toml::from_str( + r#" +[identity] +command = { executable = "company-cli", args = [ + { match = "exact", value = "mcp" }, + { match = "regex", expression = '^https://[a-z]+\.example\.com$' }, +] } +"#, + ) + .expect("command matcher"); + let url: McpServerRequirement = toml::from_str( + r#" +[identity] +url = { match = "prefix", value = "https://mcp.example.com/" } +"#, + ) + .expect("URL matcher"); + + assert_eq!( + command, + McpServerRequirement::Command(McpServerCommandMatcher { + executable: "company-cli".to_string(), + args: vec![ + McpServerValueMatcher::Exact { + value: "mcp".to_string(), + }, + McpServerValueMatcher::Regex { + expression: r"^https://[a-z]+\.example\.com$".to_string(), + }, + ], + }) + ); + assert_eq!( + url, + McpServerRequirement::Url(McpServerValueMatcher::Prefix { + value: "https://mcp.example.com/".to_string(), + }) + ); +} + +#[test] +fn requirement_rejects_matchers_outside_identity() { + for contents in [ + r#" +command = "company-cli" +"#, + r#" +command = { executable = "company-cli", args = [] } +"#, + r#" +url = { match = "prefix", value = "https://mcp.example.com/" } +"#, + ] { + let err = toml::from_str::(contents) + .expect_err("MCP server requirements should use the identity key"); + assert!( + err.to_string().contains("missing field `identity`"), + "{err}" + ); + } +} + +#[test] +fn matcher_identity_rejects_unknown_fields() { + for contents in [ + r#" +[identity] +unknown = "value" +command = { executable = "company-cli", args = [] } +"#, + r#" +[identity] +command = { executable = "company-cli", args = [], unknown = "value" } +"#, + ] { + toml::from_str::(contents) + .expect_err("matcher identities should reject unknown fields"); + } +} + +#[test] +fn identity_requirement_keeps_ignoring_unrelated_sibling_fields() { + let requirement: McpServerRequirement = toml::from_str( + r#" +unrelated = "ignored" +[identity] +command = "company-cli" +"#, + ) + .expect("legacy identity with unrelated sibling field"); + + assert_eq!( + requirement, + McpServerRequirement::Identity { + identity: McpServerIdentity::Command { + command: "company-cli".to_string(), + }, + } + ); +} diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs new file mode 100644 index 0000000000000000000000000000000000000000..6cc30861de67517b739ff6e0eed07a4df06fae74 --- /dev/null +++ b/codex-rs/config/src/mcp_types.rs @@ -0,0 +1,626 @@ +//! MCP server configuration types. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt; +use std::num::NonZeroUsize; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_protocol::config_types::ToolExposureSurface; +use codex_utils_path_uri::LegacyAppPathString; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::de::Error as SerdeError; + +use crate::McpEmaRegistration; +use crate::RequirementSource; + +/// Effective MCP environment id when config omits `environment_id`. +pub const DEFAULT_MCP_SERVER_ENVIRONMENT_ID: &str = "local"; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AppToolApproval { + #[default] + Auto, + Prompt, + Writes, + Approve, +} + +impl AppToolApproval { + /// Requires approval whenever either policy could require it. + /// + /// `Auto` and `Writes` are incomparable: each can require approval for a + /// tool the other would approve. Their conservative intersection is `Prompt`. + pub fn restrict_to(self, requested: Self) -> Self { + match (self, requested) { + (Self::Prompt, _) | (_, Self::Prompt) => Self::Prompt, + (Self::Approve, mode) | (mode, Self::Approve) => mode, + (Self::Auto, Self::Auto) => Self::Auto, + (Self::Writes, Self::Writes) => Self::Writes, + (Self::Auto, Self::Writes) | (Self::Writes, Self::Auto) => Self::Prompt, + } + } +} + +/// Human-readable reason a configured MCP server was disabled after requirements +/// were applied. +/// +/// `Display` is intentionally implemented for CLI/TUI status output; avoid +/// relying on `Debug` because enum variant syntax is not part of the user-facing +/// message contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum McpServerDisabledReason { + /// The server is disabled, but there is no more specific user-facing reason. + Unknown, + /// The server was disabled by config requirements from the given source. + Requirements { source: RequirementSource }, + /// Enterprise authorization was rejected for this registration, not its name. + EmaRegistration, +} + +impl fmt::Display for McpServerDisabledReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + McpServerDisabledReason::Unknown => write!(f, "unknown"), + McpServerDisabledReason::Requirements { source } => { + write!(f, "requirements ({source})") + } + McpServerDisabledReason::EmaRegistration => { + write!(f, "invalid enterprise registration") + } + } + } +} + +/// Per-tool settings for a single MCP server tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct McpServerToolConfig { + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, + + /// Token budget for this tool's output, before the standard 20% serialization allowance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_token_limit: Option, +} + +impl McpServerToolConfig { + /// Applies the stricter explicit output budget without changing approval policy. + pub fn restrict_output_token_limit(&mut self, limit: Option) { + self.output_token_limit = self.output_token_limit.into_iter().chain(limit).min(); + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged, deny_unknown_fields)] +pub enum McpServerEnvVar { + Name(String), + Config { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + source: Option, + }, +} + +impl McpServerEnvVar { + pub fn name(&self) -> &str { + match self { + McpServerEnvVar::Name(name) => name, + McpServerEnvVar::Config { name, .. } => name, + } + } + + pub fn source(&self) -> Option<&str> { + match self { + McpServerEnvVar::Name(_) => None, + McpServerEnvVar::Config { source, .. } => source.as_deref(), + } + } + + pub fn is_remote_source(&self) -> bool { + self.source() == Some("remote") + } + + pub fn validate_source(&self) -> Result<(), String> { + match self.source() { + None | Some("local") | Some("remote") => Ok(()), + Some(source) => Err(format!( + "unsupported env_vars source `{source}`; expected `local` or `remote`" + )), + } + } +} + +impl From for McpServerEnvVar { + fn from(value: String) -> Self { + Self::Name(value) + } +} + +impl From<&str> for McpServerEnvVar { + fn from(value: &str) -> Self { + Self::Name(value.to_string()) + } +} + +impl AsRef for McpServerEnvVar { + fn as_ref(&self) -> &str { + self.name() + } +} + +/// Client settings for MCP OAuth login or enterprise token exchange. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct McpServerOAuthConfig { + /// Explicit OAuth client identifier to present during authorization and token exchange. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, + + /// Registered callback URL associated with this OAuth client. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub callback_url: Option, + + /// Fixed callback port that takes precedence over Codex's global OAuth callback port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub callback_port: Option, + + /// Expected resource authorization server issuer for EMA token exchange. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authorization_server_issuer: Option, + + /// Host-resolved authorization; never accepted from a server or plugin declaration. + #[serde(skip)] + #[schemars(skip)] + pub ema_registration: Option, + + /// Host-policy rejection retained until catalog finalization; never deserialized. + #[serde(skip)] + #[schemars(skip)] + pub ema_registration_error: Option<&'static str>, +} + +/// Authentication flow for an HTTP MCP server. Explicit credentials take +/// precedence for OAuth and ChatGPT; EMA rejects alternate credentials and fallback. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum McpServerAuth { + /// Use stored MCP OAuth credentials when available. Starting an OAuth login + /// is a separate operation. + #[default] + #[serde(rename = "oauth")] + OAuth, + /// Use the current ChatGPT session for servers on the trusted first-party + /// ChatGPT origin. If no ChatGPT session provider is available, startup can + /// still fall back to stored OAuth credentials. + #[serde(rename = "chatgpt")] + ChatGpt, + /// Exchange an enterprise IdP refresh token for resource-specific authorization. + /// Alternate credentials and ordinary OAuth fallback are not permitted. + #[serde(rename = "ema_auth")] + EmaAuth, +} + +impl McpServerAuth { + fn is_default(&self) -> bool { + self == &Self::default() + } +} + +#[derive(Serialize, Debug, Clone, PartialEq)] +pub struct McpServerConfig { + #[serde(flatten)] + pub transport: McpServerTransportConfig, + + /// Authentication flow, including an explicit no-fallback EMA mode. + #[serde(default, skip_serializing_if = "McpServerAuth::is_default")] + pub auth: McpServerAuth, + + /// Effective environment id for where Codex should start this MCP server. + pub environment_id: String, + + /// When `false`, Codex skips initializing this MCP server. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub required: bool, + + /// When `true`, every tool from this server is advertised as safe for parallel tool calls. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub supports_parallel_tool_calls: bool, + + /// Model-facing surfaces from which this server's tools must be omitted. + /// `None` leaves lower-priority configuration unchanged; an empty list clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub omit_tools_from: Option>, + + /// Reason this server was disabled after applying requirements. + #[serde(skip)] + pub disabled_reason: Option, + + /// Startup timeout in seconds for initializing MCP server & initially listing tools. + #[serde( + default, + with = "option_duration_secs", + skip_serializing_if = "Option::is_none" + )] + pub startup_timeout_sec: Option, + + /// Default timeout for MCP tool calls initiated via this server. + #[serde(default, with = "option_duration_secs")] + pub tool_timeout_sec: Option, + + /// Approval mode for tools in this server unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + + /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, + + /// Optional scopes requested during MCP login or EMA token exchange. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scopes: Option>, + + /// Optional client settings for MCP login or EMA token exchange. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth: Option, + + /// Optional resource parameter for MCP login or EMA token exchange (RFC 8707). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_resource: Option, + + /// Per-tool settings keyed by tool name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tools: HashMap, +} + +impl McpServerConfig { + pub fn is_local_environment(&self) -> bool { + self.environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID + } + + /// Keeps local OAuth credentials compatible while reserving managed credential namespaces. + pub fn oauth_credential_name<'a>(&self, server_name: &'a str) -> Cow<'a, str> { + if self.is_local_environment() { + if server_name.starts_with("executor:") + || server_name.starts_with("local:") + || server_name.starts_with("ema-idp:") + { + Cow::Owned(format!("local:{server_name}")) + } else { + Cow::Borrowed(server_name) + } + } else { + let environment = URL_SAFE_NO_PAD.encode(self.environment_id.as_bytes()); + let server = URL_SAFE_NO_PAD.encode(server_name.as_bytes()); + Cow::Owned(format!("executor:{environment}:{server}")) + } + } + + pub fn oauth_client_id(&self) -> Option<&str> { + self.oauth + .as_ref() + .and_then(|oauth| oauth.client_id.as_deref()) + } + + pub fn oauth_callback_port(&self, global_callback_port: Option) -> Option { + let callback_port = self.oauth.as_ref().and_then(|oauth| oauth.callback_port); + if let Some(callback_port) = callback_port { + tracing::info!( + callback_port, + ?global_callback_port, + "using plugin-specific MCP OAuth callback port instead of the global callback port" + ); + } + callback_port.or(global_callback_port) + } +} + +/// Raw MCP config shape used for deserialization and supported-field JSON +/// Schema generation. +/// +/// Fields that are accepted only to produce targeted validation errors should +/// be skipped in the generated schema. +/// +/// Keep `TryFrom for McpServerConfig` exhaustively +/// destructuring this struct so new TOML fields cannot be added here without +/// updating the validation/mapping logic that produces [`McpServerConfig`]. +#[derive(Deserialize, Clone, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct RawMcpServerConfig { + // stdio + pub command: Option, + #[serde(default)] + pub args: Option>, + #[serde(default)] + pub env: Option>, + #[serde(default)] + pub env_vars: Option>, + #[serde(default)] + pub cwd: Option, + pub http_headers: Option>, + #[serde(default)] + pub env_http_headers: Option>, + + // streamable_http + pub url: Option, + #[schemars(skip)] + pub bearer_token: Option, + pub bearer_token_env_var: Option, + pub http_headers_helper: Option, + + // shared + #[serde(default)] + pub environment_id: Option, + #[serde(default)] + pub auth: Option, + #[serde(default)] + pub startup_timeout_sec: Option, + #[serde(default)] + pub startup_timeout_ms: Option, + #[serde(default, with = "option_duration_secs")] + #[schemars(with = "Option")] + pub tool_timeout_sec: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub required: Option, + #[serde(default)] + pub supports_parallel_tool_calls: Option, + #[serde(default)] + pub omit_tools_from: Option>, + #[serde(default)] + pub default_tools_approval_mode: Option, + #[serde(default)] + pub enabled_tools: Option>, + #[serde(default)] + pub disabled_tools: Option>, + #[serde(default)] + pub scopes: Option>, + #[serde(default)] + pub oauth: Option, + #[serde(default)] + pub oauth_resource: Option, + /// Legacy display-name field accepted for backward compatibility. + #[serde(default, rename = "name")] + pub _name: Option, + #[serde(default)] + pub tools: Option>, +} + +impl TryFrom for McpServerConfig { + type Error = String; + + fn try_from(raw: RawMcpServerConfig) -> Result { + let RawMcpServerConfig { + command, + args, + env, + env_vars, + cwd, + http_headers, + env_http_headers, + url, + bearer_token, + bearer_token_env_var, + http_headers_helper, + environment_id, + auth, + startup_timeout_sec, + startup_timeout_ms, + tool_timeout_sec, + enabled, + required, + supports_parallel_tool_calls, + omit_tools_from, + default_tools_approval_mode, + enabled_tools, + disabled_tools, + scopes, + oauth, + oauth_resource, + _name: _, + tools, + } = raw; + + let startup_timeout_sec = match (startup_timeout_sec, startup_timeout_ms) { + (Some(sec), _) => { + Some(Duration::try_from_secs_f64(sec).map_err(|err| err.to_string())?) + } + (None, Some(ms)) => Some(Duration::from_millis(ms)), + (None, None) => None, + }; + + fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), String> { + if value.is_none() { + return Ok(()); + } + Err(format!("{field} is not supported for {transport}")) + } + + let transport = if let Some(command) = command { + throw_if_set("stdio", "url", url.as_ref())?; + throw_if_set( + "stdio", + "bearer_token_env_var", + bearer_token_env_var.as_ref(), + )?; + throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?; + throw_if_set("stdio", "http_headers_helper", http_headers_helper.as_ref())?; + throw_if_set("stdio", "http_headers", http_headers.as_ref())?; + throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?; + throw_if_set("stdio", "oauth", oauth.as_ref())?; + throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?; + throw_if_set("stdio", "auth", auth.as_ref())?; + let env_vars = env_vars.unwrap_or_default(); + for env_var in &env_vars { + env_var.validate_source()?; + } + McpServerTransportConfig::Stdio { + command, + args: args.unwrap_or_default(), + env, + env_vars, + cwd, + } + } else if let Some(url) = url { + throw_if_set("streamable_http", "args", args.as_ref())?; + throw_if_set("streamable_http", "env", env.as_ref())?; + throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?; + throw_if_set("streamable_http", "cwd", cwd.as_ref())?; + throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?; + if http_headers_helper + .as_deref() + .is_some_and(|command| command.trim().is_empty()) + { + return Err("http_headers_helper must not be empty".to_string()); + } + if environment_id + .as_deref() + .is_some_and(|environment_id| environment_id != DEFAULT_MCP_SERVER_ENVIRONMENT_ID) + && http_headers_helper.is_some() + { + return Err( + "http_headers_helper is only supported for local MCP servers".to_string(), + ); + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + http_headers_helper, + } + } else { + return Err("invalid transport".to_string()); + }; + + let environment_id = + environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string()); + let auth = auth.unwrap_or_default(); + if !matches!(auth, McpServerAuth::EmaAuth) + && oauth + .as_ref() + .is_some_and(|oauth| oauth.authorization_server_issuer.is_some()) + { + return Err( + "oauth.authorization_server_issuer requires auth = \"ema_auth\"".to_string(), + ); + } + + Ok(Self { + transport, + auth, + environment_id, + startup_timeout_sec, + tool_timeout_sec, + enabled: enabled.unwrap_or_else(default_enabled), + required: required.unwrap_or_default(), + supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(), + omit_tools_from, + disabled_reason: None, + default_tools_approval_mode, + enabled_tools, + disabled_tools, + scopes, + oauth, + oauth_resource, + tools: tools.unwrap_or_default(), + }) + } +} + +impl<'de> Deserialize<'de> for McpServerConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + RawMcpServerConfig::deserialize(deserializer)? + .try_into() + .map_err(SerdeError::custom) + } +} + +const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] +pub enum McpServerTransportConfig { + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + env: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + }, + /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http + StreamableHttp { + url: String, + /// Name of the environment variable to read for an HTTP bearer token. + /// When set, requests will include the token via `Authorization: Bearer `. + /// The actual secret value must be provided via the environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + bearer_token_env_var: Option, + /// Additional HTTP headers to include in requests to this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + http_headers: Option>, + /// HTTP headers where the value is sourced from an environment variable. + #[serde(default, skip_serializing_if = "Option::is_none")] + env_http_headers: Option>, + /// Local-only shell command that prints a JSON object of dynamic HTTP headers. + /// The command may be visible to local process inspection; do not embed credentials. + #[serde(default, skip_serializing_if = "Option::is_none")] + http_headers_helper: Option, + }, +} + +mod option_duration_secs { + use serde::Deserialize; + use serde::Deserializer; + use serde::Serializer; + use std::time::Duration; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(duration) => serializer.serialize_some(&duration.as_secs_f64()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let secs = Option::::deserialize(deserializer)?; + secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) + .transpose() + } +} + +#[cfg(test)] +#[path = "mcp_types_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..2ccb848b19aabbe1b131254c64168fdda5744d97 --- /dev/null +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -0,0 +1,663 @@ +use super::*; +use codex_utils_path_uri::LegacyAppPathString; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; + +#[test] +fn app_tool_approval_restrictions_never_weaken_either_policy() { + use AppToolApproval::Approve; + use AppToolApproval::Auto; + use AppToolApproval::Prompt; + use AppToolApproval::Writes; + + let modes = [Approve, Auto, Writes, Prompt]; + let expected = [ + [Approve, Auto, Writes, Prompt], + [Auto, Auto, Prompt, Prompt], + [Writes, Prompt, Writes, Prompt], + [Prompt, Prompt, Prompt, Prompt], + ]; + + for (parent_index, parent) in modes.into_iter().enumerate() { + for (requested_index, requested) in modes.into_iter().enumerate() { + assert_eq!( + parent.restrict_to(requested), + expected[parent_index][requested_index], + "parent: {parent:?}, requested: {requested:?}", + ); + } + } +} + +#[test] +fn deserialize_stdio_command_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); + assert!(!cfg.required); + assert_eq!(cfg.omit_tools_from, None); + assert!(cfg.enabled_tools.is_none()); + assert!(cfg.disabled_tools.is_none()); +} + +#[test] +fn deserialize_stdio_command_server_config_with_args() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_remote_stdio_server_accepts_foreign_absolute_cwd() { + #[cfg(not(windows))] + let cwd = r"C:\Users\openai\share"; + #[cfg(windows)] + let cwd = "/home/openai/share"; + let expected_cwd = LegacyAppPathString::from_path(Path::new(cwd)); + let cfg: McpServerConfig = match toml::from_str(&format!( + r#" + command = "echo" + environment_id = "remote" + cwd = {cwd:?} + "# + )) { + Ok(cfg) => cfg, + Err(error) => panic!("remote stdio MCP should accept absolute cwd: {error}"), + }; + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(expected_cwd), + } + ); +} + +#[test] +fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + args = ["hello", "world"] + env = { "FOO" = "BAR" } + "#, + ) + .expect("should deserialize command config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["hello".to_string(), "world".to_string()], + env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), + env_vars: Vec::new(), + cwd: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_stdio_command_server_config_with_env_vars() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = ["FOO", "BAR"] + "#, + ) + .expect("should deserialize command config with env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec!["FOO".into(), "BAR".into()], + cwd: None, + } + ); +} + +#[test] +fn deserialize_stdio_command_server_config_with_env_var_sources() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = [ + "LEGACY_TOKEN", + { name = "LOCAL_TOKEN", source = "local" }, + { name = "REMOTE_TOKEN", source = "remote" }, + ] + "#, + ) + .expect("should deserialize command config with sourced env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec![ + McpServerEnvVar::Name("LEGACY_TOKEN".to_string()), + McpServerEnvVar::Config { + name: "LOCAL_TOKEN".to_string(), + source: Some("local".to_string()), + }, + McpServerEnvVar::Config { + name: "REMOTE_TOKEN".to_string(), + source: Some("remote".to_string()), + }, + ], + cwd: None, + } + ); +} + +#[test] +fn deserialize_stdio_command_server_config_rejects_unknown_env_var_source() { + let err = toml::from_str::( + r#" + command = "echo" + env_vars = [{ name = "TOKEN", source = "elsewhere" }] + "#, + ) + .expect_err("unsupported env var source should be rejected"); + + assert!( + err.to_string() + .contains("unsupported env_vars source `elsewhere`"), + "unexpected error: {err}" + ); +} + +#[test] +fn deserialize_stdio_command_server_config_with_cwd() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + cwd = "/tmp" + "#, + ) + .expect("should deserialize command config with cwd"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(LegacyAppPathString::from_path(Path::new("/tmp"))), + } + ); +} + +#[test] +fn deserialize_disabled_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled = false + "#, + ) + .expect("should deserialize disabled server config"); + + assert!(!cfg.enabled); + assert!(!cfg.required); +} + +#[test] +fn deserialize_required_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + required = true + "#, + ) + .expect("should deserialize required server config"); + + assert!(cfg.required); +} + +#[test] +fn deserialize_streamable_http_server_config() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + http_headers_helper: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_streamable_http_server_config_with_env_var() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + bearer_token_env_var = "GITHUB_TOKEN" + "#, + ) + .expect("should deserialize http config"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + http_headers_helper: None, + } + ); + assert!(cfg.enabled); +} + +#[test] +fn deserialize_streamable_http_server_config_with_headers() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + http_headers = { "X-Foo" = "bar" } + env_http_headers = { "X-Token" = "TOKEN_ENV" } + http_headers_helper = "auth-cli headers" + "#, + ) + .expect("should deserialize http config with headers"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Token".to_string(), + "TOKEN_ENV".to_string() + )])), + http_headers_helper: Some("auth-cli headers".to_string()), + } + ); +} + +#[test] +fn rejects_http_headers_helper_outside_local_http_servers() { + for contents in [ + "command = \"server\"\nhttp_headers_helper = \"auth-cli headers\"", + "url = \"https://example.com/mcp\"\nhttp_headers_helper = \" \"", + "url = \"https://example.com/mcp\"\nenvironment_id = \"remote\"\nhttp_headers_helper = \"auth-cli headers\"", + ] { + toml::from_str::(contents).expect_err("invalid helper placement"); + } +} + +#[test] +fn deserialize_streamable_http_server_config_with_oauth_resource() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + oauth_resource = "https://api.example.com" + "#, + ) + .expect("should deserialize http config with oauth_resource"); + + assert_eq!( + cfg.oauth_resource, + Some("https://api.example.com".to_string()) + ); +} + +#[test] +fn deserialize_streamable_http_server_config_with_oauth_client_id() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + + [oauth] + client_id = "eci-prd-pub-codex-123" + callback_url = "http://127.0.0.1/callback/registered" + callback_port = 9876 + "#, + ) + .expect("should deserialize http config with oauth client id"); + + assert_eq!( + cfg.oauth, + Some(McpServerOAuthConfig { + client_id: Some("eci-prd-pub-codex-123".to_string()), + callback_url: Some("http://127.0.0.1/callback/registered".to_string()), + callback_port: Some(9876), + ..Default::default() + }) + ); +} + +#[test] +fn oauth_callback_port_prefers_server_port_over_global_port() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + + [oauth] + callback_port = 9876 + "#, + ) + .expect("should deserialize http config with oauth callback port"); + + assert_eq!(cfg.oauth_callback_port(Some(4321)), Some(9876)); +} + +#[test] +fn oauth_callback_port_falls_back_to_global_port() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + "#, + ) + .expect("should deserialize http config without oauth callback port"); + + assert_eq!(cfg.oauth_callback_port(Some(4321)), Some(4321)); + assert_eq!(cfg.oauth_callback_port(/*global_callback_port*/ None), None); +} + +#[test] +fn deserialize_server_config_with_tool_filters() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + enabled_tools = ["allowed"] + disabled_tools = ["blocked"] + "#, + ) + .expect("should deserialize tool filters"); + + assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()])); + assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); +} + +#[test] +fn deserialize_server_config_with_parallel_tool_calls() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + supports_parallel_tool_calls = true + "#, + ) + .expect("should deserialize supports_parallel_tool_calls"); + + assert!(cfg.supports_parallel_tool_calls); +} + +#[test] +fn serialize_round_trips_server_config_with_omitted_tool_exposure_surfaces() { + for omitted_surfaces in [ + vec![], + vec![ToolExposureSurface::CodeMode], + vec![ToolExposureSurface::Deferred], + vec![ToolExposureSurface::Direct], + vec![ToolExposureSurface::CodeMode, ToolExposureSurface::Deferred], + vec![ToolExposureSurface::CodeMode, ToolExposureSurface::Direct], + vec![ToolExposureSurface::Deferred, ToolExposureSurface::Direct], + vec![ + ToolExposureSurface::CodeMode, + ToolExposureSurface::Deferred, + ToolExposureSurface::Direct, + ], + ] { + let serialized_surfaces = omitted_surfaces + .iter() + .map(|surface| format!("\"{surface}\"")) + .collect::>() + .join(", "); + let config = format!("command = \"echo\"\nomit_tools_from = [{serialized_surfaces}]\n"); + let cfg: McpServerConfig = + toml::from_str(&config).expect("should deserialize omitted MCP exposure surfaces"); + assert_eq!(cfg.omit_tools_from, Some(omitted_surfaces.clone())); + + let serialized = toml::to_string(&cfg).expect("should serialize MCP config"); + assert!(serialized.contains(&format!("omit_tools_from = [{serialized_surfaces}]"))); + + let round_tripped: McpServerConfig = + toml::from_str(&serialized).expect("should deserialize serialized MCP config"); + assert_eq!(round_tripped, cfg); + } +} + +#[test] +fn deserialize_server_config_with_default_tool_approval_mode() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + default_tools_approval_mode = "approve" + + [tools.search] + approval_mode = "prompt" + output_token_limit = 30000 + "#, + ) + .expect("should deserialize default tool approval mode"); + + assert_eq!( + cfg.default_tools_approval_mode, + Some(AppToolApproval::Approve) + ); + assert_eq!( + cfg.tools.get("search"), + Some(&McpServerToolConfig { + approval_mode: Some(AppToolApproval::Prompt), + output_token_limit: std::num::NonZeroUsize::new(30_000), + }) + ); + + let serialized = toml::to_string(&cfg).expect("should serialize MCP config"); + assert!(serialized.contains("default_tools_approval_mode = \"approve\"")); + assert!(serialized.contains("output_token_limit = 30000")); + + let round_tripped: McpServerConfig = + toml::from_str(&serialized).expect("should deserialize serialized MCP config"); + assert_eq!(round_tripped, cfg); +} + +#[test] +fn deserialize_rejects_nonpositive_mcp_tool_output_limits() { + for output_token_limit in [0, -1] { + let config = format!( + "command = \"echo\"\n[tools.search]\noutput_token_limit = {output_token_limit}\n" + ); + let error = toml::from_str::(&config) + .expect_err("MCP tool output limit must be positive"); + assert!(error.to_string().contains("output_token_limit")); + } +} + +#[test] +fn serialize_round_trips_server_config_with_parallel_tool_calls() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + supports_parallel_tool_calls = true + tool_timeout_sec = 2.0 + "#, + ) + .expect("should deserialize supports_parallel_tool_calls"); + + let serialized = toml::to_string(&cfg).expect("should serialize MCP config"); + assert!(serialized.contains("supports_parallel_tool_calls = true")); + + let round_tripped: McpServerConfig = + toml::from_str(&serialized).expect("should deserialize serialized MCP config"); + assert_eq!(round_tripped, cfg); +} + +#[test] +fn deserialize_ignores_unknown_server_fields() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + trust_level = "trusted" + "#, + ) + .expect("should ignore unknown server fields"); + + assert_eq!( + cfg, + McpServerConfig { + auth: Default::default(), + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + omit_tools_from: None, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + } + ); +} + +#[test] +fn deserialize_rejects_command_and_url() { + toml::from_str::( + r#" + command = "echo" + url = "https://example.com" + "#, + ) + .expect_err("should reject command+url"); +} + +#[test] +fn deserialize_rejects_env_for_http_transport() { + toml::from_str::( + r#" + url = "https://example.com" + env = { "FOO" = "BAR" } + "#, + ) + .expect_err("should reject env for http transport"); +} + +#[test] +fn deserialize_rejects_headers_for_stdio() { + toml::from_str::( + r#" + command = "echo" + http_headers = { "X-Foo" = "bar" } + "#, + ) + .expect_err("should reject http_headers for stdio transport"); + + toml::from_str::( + r#" + command = "echo" + env_http_headers = { "X-Foo" = "BAR_ENV" } + "#, + ) + .expect_err("should reject env_http_headers for stdio transport"); + + let err = toml::from_str::( + r#" + command = "echo" + oauth = { client_id = "eci-prd-pub-codex-123" } + "#, + ) + .expect_err("should reject oauth for stdio transport"); + + assert!( + err.to_string().contains("oauth is not supported for stdio"), + "unexpected error: {err}" + ); + + let err = toml::from_str::( + r#" + command = "echo" + oauth_resource = "https://api.example.com" + "#, + ) + .expect_err("should reject oauth_resource for stdio transport"); + + assert!( + err.to_string() + .contains("oauth_resource is not supported for stdio"), + "unexpected error: {err}" + ); +} + +#[test] +fn deserialize_rejects_inline_bearer_token_field() { + let err = toml::from_str::( + r#" + url = "https://example.com" + bearer_token = "secret" + "#, + ) + .expect_err("should reject bearer_token field"); + + assert!( + err.to_string().contains("bearer_token is not supported"), + "unexpected error: {err}" + ); +} diff --git a/codex-rs/config/src/merge.rs b/codex-rs/config/src/merge.rs new file mode 100644 index 0000000000000000000000000000000000000000..15fde6a37ce2b83dad1f641831a698b216403482 --- /dev/null +++ b/codex-rs/config/src/merge.rs @@ -0,0 +1,236 @@ +use crate::key_aliases::normalize_key_aliases; +use crate::key_aliases::normalized_with_key_aliases; +use codex_network_proxy::normalize_host; +use toml::Value as TomlValue; + +/// The mutually exclusive shell-environment filter representations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShellEnvironmentPolicyFilterRepresentation { + Filters, + Legacy, +} + +impl ShellEnvironmentPolicyFilterRepresentation { + /// Returns the representation selected by a policy table, including empty fields. + pub fn from_policy(policy: &TomlValue) -> Option { + let policy = policy.as_table()?; + if policy.contains_key("filters") { + Some(Self::Filters) + } else if policy.contains_key("exclude") || policy.contains_key("include_only") { + Some(Self::Legacy) + } else { + None + } + } + + /// Returns the representation addressed by a dotted config path. + pub fn from_path(path: &[String]) -> Option { + match path { + [policy, field, ..] if policy == "shell_environment_policy" => match field.as_str() { + "filters" => Some(Self::Filters), + "exclude" | "include_only" => Some(Self::Legacy), + _ => None, + }, + _ => None, + } + } + + /// Returns the representation selected by an edit at `path`. + pub fn from_edit(path: &[String], value: &TomlValue) -> Option { + if matches!(path, [policy] if policy == "shell_environment_policy") { + Self::from_policy(value) + } else { + Self::from_path(path) + } + } + + /// Returns the policy fields that must be removed when this representation is selected. + pub fn displaced_fields(self) -> &'static [&'static str] { + match self { + Self::Filters => &["exclude", "include_only"], + Self::Legacy => &["filters"], + } + } +} + +/// Merge config `overlay` into `base`, giving `overlay` precedence. +pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { + merge_toml_values_at_path(base, overlay, &mut Vec::new()); +} + +pub fn is_structured_feature_path>(path: &[S]) -> bool { + let (features, feature) = match path { + [profiles, _, features, feature] if profiles.as_ref() == "profiles" => (features, feature), + [features, feature] => (features, feature), + _ => return false, + }; + + features.as_ref() == "features" + && matches!( + feature.as_ref(), + "multi_agent_v2" | "network_proxy" | "sleep_tool" + ) +} + +fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &mut Vec) { + replace_shell_environment_policy_filter_representation(base, overlay, path); + + if is_structured_feature_path(path) { + if let TomlValue::Boolean(enabled) = base + && overlay.is_table() + { + *base = TomlValue::Table(toml::map::Map::from_iter([( + "enabled".to_string(), + TomlValue::Boolean(*enabled), + )])); + } else if let TomlValue::Table(table) = base + && let TomlValue::Boolean(enabled) = overlay + { + table.insert("enabled".to_string(), TomlValue::Boolean(*enabled)); + return; + } + } + + if let TomlValue::Table(overlay_table) = overlay + && let TomlValue::Table(base_table) = base + { + normalize_key_aliases(path, base_table); + let mut overlay_table = overlay_table.clone(); + normalize_key_aliases(path, &mut overlay_table); + if is_network_domains_path(path) { + normalize_network_domain_keys(base_table); + normalize_network_domain_keys(&mut overlay_table); + } + if is_shell_environment_filters_path(path) { + normalize_case_insensitive_keys(base_table); + normalize_case_insensitive_keys(&mut overlay_table); + } + if path.split_last().is_some_and(|(field, feature_path)| { + field == "credentials" && is_structured_feature_path(feature_path) + }) { + for (provider_id, provider) in &overlay_table { + let Some(overlay_sources) = provider.get("env").and_then(TomlValue::as_array) + else { + continue; + }; + base_table.retain(|existing_id, existing| { + existing_id == provider_id + || !overlay_table + .get(existing_id) + .and_then(|provider| provider.get("env")) + .or_else(|| existing.get("env")) + .and_then(TomlValue::as_array) + .is_some_and(|existing_sources| { + existing_sources.iter().filter_map(TomlValue::as_str).any( + |existing_source| { + overlay_sources.iter().filter_map(TomlValue::as_str).any( + |overlay_source| { + existing_source == overlay_source + || cfg!(windows) + && existing_source + .eq_ignore_ascii_case(overlay_source) + }, + ) + }, + ) + }) + }); + } + } + for (key, value) in overlay_table { + path.push(key.clone()); + if let Some(existing) = base_table.get_mut(&key) { + merge_toml_values_at_path(existing, &value, path); + } else { + base_table.insert(key, normalized_with_key_aliases(&value, path)); + } + path.pop(); + } + } else { + *base = normalized_with_key_aliases(overlay, path); + } +} + +fn is_shell_environment_filters_path(path: &[String]) -> bool { + matches!( + path, + [policy, filters] + if policy == "shell_environment_policy" && filters == "filters" + ) +} + +/// Switching between legacy arrays and keyed filters replaces lower filter +/// fields instead of attempting to reconcile the two representations. Legacy +/// arrays already replace wholesale, so reconciling them would add merge +/// semantics that the legacy representation never supported. +fn replace_shell_environment_policy_filter_representation( + base: &mut TomlValue, + overlay: &TomlValue, + path: &[String], +) { + if !matches!(path, [policy] if policy == "shell_environment_policy") { + return; + } + let Some(overlay_representation) = + ShellEnvironmentPolicyFilterRepresentation::from_policy(overlay) + else { + return; + }; + let TomlValue::Table(base) = base else { + return; + }; + + for field in overlay_representation.displaced_fields() { + base.remove(*field); + } +} + +/// Looks up a shell-environment filter pattern while ignoring case. +pub fn shell_environment_filter_entry<'a>( + root: &'a TomlValue, + path: &[String], +) -> Option<(&'a String, &'a TomlValue)> { + let [policy, filters, pattern] = path else { + return None; + }; + if policy != "shell_environment_policy" || filters != "filters" { + return None; + } + + let pattern = pattern.to_lowercase(); + root.get(policy)? + .get(filters)? + .as_table()? + .iter() + .find(|(candidate, _)| candidate.to_lowercase() == pattern) +} + +fn is_network_domains_path(path: &[String]) -> bool { + matches!( + path, + [permissions, _, network, domains] + if permissions == "permissions" && network == "network" && domains == "domains" + ) || matches!( + path, + [application, network, domains] + if application == "application" && network == "network" && domains == "domains" + ) +} + +fn normalize_network_domain_keys(table: &mut toml::map::Map) { + let entries = std::mem::take(table); + for (pattern, value) in entries { + table.insert(normalize_host(&pattern), value); + } +} + +fn normalize_case_insensitive_keys(table: &mut toml::map::Map) { + let entries = std::mem::take(table); + for (key, value) in entries { + table.insert(key.to_lowercase(), value); + } +} + +#[cfg(test)] +#[path = "merge_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/merge_tests.rs b/codex-rs/config/src/merge_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..024d2639fd4fd1b3bde7bb9830d7850d559db7b9 --- /dev/null +++ b/codex-rs/config/src/merge_tests.rs @@ -0,0 +1,692 @@ +use super::*; +use crate::config_toml::AgentsToml; +use crate::config_toml::ConfigToml; +use crate::types::MemoriesToml; +use pretty_assertions::assert_eq; + +fn parse_toml(value: &str) -> TomlValue { + toml::from_str(value).expect("TOML should parse") +} + +#[test] +fn merge_toml_values_normalizes_legacy_key_from_base_layer() { + let mut base = parse_toml( + r#" +[memories] +no_memories_if_mcp_or_web_search = false +"#, + ); + let overlay = parse_toml( + r#" +[memories] +disable_on_external_context = true +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[memories] +disable_on_external_context = true +"#, + ); + assert_eq!(base, expected); + + let config: ConfigToml = base.try_into().expect("merged config should deserialize"); + assert_eq!( + config.memories, + Some(MemoriesToml { + disable_on_external_context: Some(true), + ..Default::default() + }) + ); +} + +#[test] +fn merge_toml_values_normalizes_legacy_key_from_overlay_layer() { + let mut base = parse_toml( + r#" +[memories] +disable_on_external_context = false +"#, + ); + let overlay = parse_toml( + r#" +[memories] +no_memories_if_mcp_or_web_search = true +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[memories] +disable_on_external_context = true +"#, + ); + assert_eq!(base, expected); + + let config: ConfigToml = base.try_into().expect("merged config should deserialize"); + assert_eq!( + config.memories, + Some(MemoriesToml { + disable_on_external_context: Some(true), + ..Default::default() + }) + ); +} + +#[test] +fn merge_toml_values_prefers_canonical_key_when_one_layer_has_both_names() { + let mut base = TomlValue::Table(toml::map::Map::new()); + let overlay = parse_toml( + r#" +[memories] +disable_on_external_context = true +no_memories_if_mcp_or_web_search = false +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[memories] +disable_on_external_context = true +"#, + ); + assert_eq!(base, expected); +} + +#[test] +fn merge_toml_values_normalizes_legacy_agents_key_across_layers() { + let mut base = parse_toml( + r#" +[agents] +max_threads = 4 +"#, + ); + let overlay = parse_toml( + r#" +[agents] +max_concurrent_threads_per_session = 7 +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[agents] +max_concurrent_threads_per_session = 7 +"#, + ); + assert_eq!(base, expected); + + let config: ConfigToml = base.try_into().expect("merged config should deserialize"); + assert_eq!( + config.agents, + Some(AgentsToml { + max_concurrent_threads_per_session: Some(7), + ..Default::default() + }) + ); +} + +#[test] +fn merge_toml_values_normalizes_legacy_agents_key_from_overlay() { + let mut base = parse_toml( + r#" +[agents] +max_concurrent_threads_per_session = 4 +"#, + ); + let overlay = parse_toml( + r#" +[agents] +max_threads = 7 +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[agents] +max_concurrent_threads_per_session = 7 +"#, + ); + assert_eq!(base, expected); +} + +/// Feature tables added above legacy toggles retain the lower layer's enabled state. +#[test] +fn merge_multi_agent_v2_table_preserves_legacy_boolean_toggle() { + for feature_path in ["features", "profiles.work.features"] { + let mut base = parse_toml(&format!("[{feature_path}]\nmulti_agent_v2 = true\n")); + let overlay = parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )); + + merge_toml_values(&mut base, &overlay); + + assert_eq!( + base, + parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )) + ); + } +} + +/// Legacy feature toggles update enabled state without discarding nested configuration. +#[test] +fn merge_multi_agent_v2_boolean_preserves_existing_feature_table() { + for feature_path in ["features", "profiles.work.features"] { + let mut base = parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )); + let overlay = parse_toml(&format!("[{feature_path}]\nmulti_agent_v2 = false\n")); + + merge_toml_values(&mut base, &overlay); + + assert_eq!( + base, + parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = false\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )) + ); + } +} + +/// Opaque desktop settings retain ordinary scalar/table replacement semantics. +#[test] +fn merge_multi_agent_v2_compatibility_excludes_opaque_desktop_paths() { + let cases = [ + ( + "[desktop.features.multi_agent_v2]\nenabled = true\n", + "[desktop.features]\nmulti_agent_v2 = false\n", + "[desktop.features]\nmulti_agent_v2 = false\n", + ), + ( + "[desktop.features]\nmulti_agent_v2 = true\n", + "[desktop.features.multi_agent_v2]\ncustom = true\n", + "[desktop.features.multi_agent_v2]\ncustom = true\n", + ), + ]; + + for (base, overlay, expected) in cases { + let mut base = parse_toml(base); + merge_toml_values(&mut base, &parse_toml(overlay)); + assert_eq!(base, parse_toml(expected)); + } +} + +/// CLI overrides preserve the multi-agent toggle and nested options in either ordering. +#[test] +fn multi_agent_v2_cli_overrides_preserve_boolean_and_nested_configuration() { + for feature_path in ["features", "profiles.work.features"] { + let instructions = ( + format!("{feature_path}.multi_agent_v2.subagent_usage_hint_text"), + TomlValue::String("Delegate carefully.".to_string()), + ); + let enabled = ( + format!("{feature_path}.multi_agent_v2"), + TomlValue::Boolean(true), + ); + let feature_table = ( + format!("{feature_path}.multi_agent_v2"), + parse_toml("subagent_usage_hint_text = \"Delegate carefully.\"\n"), + ); + let expected = parse_toml(&format!( + "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n", + )); + + for overrides in [ + vec![enabled.clone(), instructions.clone()], + vec![instructions, enabled.clone()], + vec![enabled.clone(), feature_table.clone()], + vec![feature_table, enabled], + ] { + assert_eq!(crate::build_cli_overrides_layer(&overrides), expected); + } + } +} + +#[test] +fn sleep_tool_overrides_preserve_disabled_state_and_mode() { + for feature_path in ["features", "profiles.work.features"] { + let disabled = ( + format!("{feature_path}.sleep_tool"), + TomlValue::Boolean(false), + ); + let mode = ( + format!("{feature_path}.sleep_tool.mode"), + TomlValue::String("always_on".to_string()), + ); + let expected = parse_toml(&format!( + "[{feature_path}.sleep_tool]\nenabled = false\nmode = \"always_on\"\n", + )); + for overrides in [vec![disabled.clone(), mode.clone()], vec![mode, disabled]] { + assert_eq!(crate::build_cli_overrides_layer(&overrides), expected); + } + + let boolean_layer = parse_toml(&format!("[{feature_path}]\nsleep_tool = false\n")); + let table_layer = parse_toml(&format!( + "[{feature_path}.sleep_tool]\nmode = \"always_on\"\n", + )); + for (mut base, overlay) in [ + (boolean_layer.clone(), table_layer.clone()), + (table_layer, boolean_layer), + ] { + merge_toml_values(&mut base, &overlay); + assert_eq!(base, expected); + } + } +} + +#[test] +fn network_proxy_feature_overrides_preserve_credential_broker_configuration() { + let enabled = ( + "features.network_proxy".to_string(), + TomlValue::Boolean(true), + ); + let broker = ( + "features.network_proxy.credential_broker".to_string(), + TomlValue::Boolean(true), + ); + let expected = + parse_toml("[features.network_proxy]\nenabled = true\ncredential_broker = true\n"); + + for overrides in [vec![enabled.clone(), broker.clone()], vec![broker, enabled]] { + assert_eq!(crate::build_cli_overrides_layer(&overrides), expected); + } + + let mut base = parse_toml("[features]\nnetwork_proxy = true\n"); + merge_toml_values( + &mut base, + &parse_toml("[features.network_proxy]\ncredential_broker = true\n"), + ); + assert_eq!(base, expected); +} + +#[test] +fn higher_priority_credential_provider_replaces_overlapping_sources() { + for (feature_path, base_source, overlay_source, keep_default) in [ + ("features", "VENDOR_TOKEN", "VENDOR_TOKEN", false), + ( + "profiles.work.features", + "VENDOR_TOKEN", + "VENDOR_TOKEN", + false, + ), + ("features", "vendor_token", "VENDOR_TOKEN", !cfg!(windows)), + ] { + let default_provider = format!( + "[{feature_path}.network_proxy.credentials.default]\n\ + env = ['{base_source}']\n\ + url_prefixes = ['https://default.example']\n" + ); + let shared_provider = format!( + "[{feature_path}.network_proxy.credentials.shared]\n\ + env = ['SHARED_TOKEN']\n\ + patterns = ['shared_[a-z]+']\n" + ); + let mut base = parse_toml(&format!("{default_provider}\n{shared_provider}")); + let overlay = parse_toml(&format!( + "[{feature_path}.network_proxy.credentials.override]\n\ + env = ['{overlay_source}']\n\ + url_prefixes = ['https://override.example']\n\ + [{feature_path}.network_proxy.credentials.shared]\n\ + url_prefixes = ['https://shared.example']\n" + )); + + merge_toml_values(&mut base, &overlay); + + let retained_default = if keep_default { &default_provider } else { "" }; + let expected = parse_toml(&format!( + "{retained_default}\n\ + [{feature_path}.network_proxy.credentials.override]\n\ + env = ['{overlay_source}']\n\ + url_prefixes = ['https://override.example']\n\ + [{feature_path}.network_proxy.credentials.shared]\n\ + env = ['SHARED_TOKEN']\n\ + patterns = ['shared_[a-z]+']\n\ + url_prefixes = ['https://shared.example']\n" + )); + assert_eq!(base, expected, "feature path: {feature_path}"); + } +} + +#[test] +fn credential_provider_remapping_preserves_inherited_settings() { + let base = parse_toml( + r#" +[features.network_proxy.credentials.a] +env = ["OLD_AUTH"] +patterns = ["acme_[a-z]{24}"] +url_prefixes = ["https://a.example"] +auth = ["token"] +[features.network_proxy.credentials.b] +env = ["OTHER_AUTH"] +patterns = ["other_[a-z]{24}"] +url_prefixes = ["https://b.example"] +auth = ["bearer"] +"#, + ); + + for new_a in ["NEW_AUTH", "OTHER_AUTH"] { + let mut merged = base.clone(); + let mut expected = base.clone(); + let providers = &mut expected["features"]["network_proxy"]["credentials"]; + providers["a"]["env"] = TomlValue::Array(vec![new_a.into()]); + providers["b"]["env"] = TomlValue::Array(vec!["OLD_AUTH".into()]); + let overlay = parse_toml(&format!( + "[features.network_proxy.credentials.a]\n\ + env = ['{new_a}']\n\ + [features.network_proxy.credentials.b]\n\ + env = ['OLD_AUTH']\n" + )); + + merge_toml_values(&mut merged, &overlay); + + assert_eq!(merged, expected); + } +} + +/// Repeated opaque desktop overrides continue to replace their previous value. +#[test] +fn multi_agent_v2_cli_compatibility_excludes_opaque_desktop_paths() { + let path = "desktop.features.multi_agent_v2".to_string(); + let enabled = (path.clone(), TomlValue::Boolean(true)); + let feature_table = (path, parse_toml("custom = true\n")); + + assert_eq!( + crate::build_cli_overrides_layer(&[enabled.clone(), feature_table.clone()]), + parse_toml("[desktop.features.multi_agent_v2]\ncustom = true\n") + ); + assert_eq!( + crate::build_cli_overrides_layer(&[feature_table, enabled]), + parse_toml("[desktop.features]\nmulti_agent_v2 = true\n") + ); +} + +#[test] +fn merge_toml_values_normalizes_permission_network_domains_before_overlaying() { + let mut base = parse_toml( + r#" +[permissions.dev.network.domains] +"example.com" = "deny" +"#, + ); + let overlay = parse_toml( + r#" +[permissions.dev.network.domains] +"EXAMPLE.COM" = "allow" +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[permissions.dev.network.domains] +"example.com" = "allow" +"#, + ); + assert_eq!(base, expected); +} + +#[test] +fn shell_environment_policy_legacy_array_overlay_replaces_legacy_array() { + let mut base = parse_toml( + r#" +[shell_environment_policy] +exclude = ["LOW_*", "SHARED_*"] +"#, + ); + let overlay = parse_toml( + r#" +[shell_environment_policy] +exclude = ["HIGH_*"] +"#, + ); + + merge_toml_values(&mut base, &overlay); + + assert_eq!(base, overlay); +} + +#[test] +fn shell_environment_policy_set_overlay_preserves_case_distinct_keys() { + let mut base = parse_toml( + r#" +[features.network_proxy.credentials.stripe] +env = ["STRIPE_API_KEY"] +patterns = ["stripe_[a-z]+"] +url_prefix_from_env = "STRIPE_HOST" + +[shell_environment_policy.set] +stripe_api_key = "stale" +stripe_host = "stale.example" +foo = "lowercase" +"#, + ); + let overlay = parse_toml( + r#" +[shell_environment_policy.set] +STRIPE_API_KEY = "trusted" +STRIPE_HOST = "trusted.example" +FOO = "uppercase" +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let values = base["shell_environment_policy"]["set"] + .as_table() + .expect("shell environment overrides"); + assert_eq!( + values.get("foo").and_then(TomlValue::as_str), + Some("lowercase") + ); + assert_eq!( + values.get("FOO").and_then(TomlValue::as_str), + Some("uppercase") + ); + assert_eq!(values.len(), 6); +} + +#[test] +fn later_credential_provider_preserves_case_distinct_environment_overrides() { + let mut base = parse_toml( + "[shell_environment_policy.set]\n\ + stripe_api_key = 'stale'\n\ + stripe_host = 'stale.example'", + ); + let override_layer = parse_toml( + "[shell_environment_policy.set]\n\ + STRIPE_API_KEY = 'previous'\n\ + STRIPE_HOST = 'previous.example'", + ); + let final_override_layer = parse_toml( + "[shell_environment_policy.set]\n\ + stripe_api_key = 'trusted'\n\ + stripe_host = 'current.example'", + ); + let provider_layer = parse_toml( + "[features.network_proxy.credentials.stripe]\n\ + env = ['STRIPE_API_KEY']\n\ + url_prefix_from_env = 'STRIPE_HOST'", + ); + + merge_toml_values(&mut base, &override_layer); + merge_toml_values(&mut base, &final_override_layer); + merge_toml_values(&mut base, &provider_layer); + + assert_eq!( + base["shell_environment_policy"]["set"], + parse_toml( + "stripe_api_key = 'trusted'\n\ + stripe_host = 'current.example'\n\ + STRIPE_API_KEY = 'previous'\n\ + STRIPE_HOST = 'previous.example'", + ) + ); +} + +#[test] +fn shell_environment_policy_filters_overlay_merges_by_key_case_insensitively() { + let mut base = parse_toml( + r#" +[shell_environment_policy.filters] +"FLIP_*" = "exclude" +"KEEP_*" = "include" +"#, + ); + let overlay = parse_toml( + r#" +[shell_environment_policy.filters] +"ADD_*" = "exclude" +"flip_*" = "include" +"#, + ); + + merge_toml_values(&mut base, &overlay); + + assert_eq!( + base, + parse_toml( + r#" +[shell_environment_policy.filters] +"add_*" = "exclude" +"flip_*" = "include" +"keep_*" = "include" +"#, + ) + ); +} + +#[test] +fn shell_environment_policy_filters_overlay_merges_unicode_keys_case_insensitively() { + let mut base = parse_toml( + r#" +[shell_environment_policy.filters] +"СЕКРЕТ_*" = "exclude" +"#, + ); + let overlay = parse_toml( + r#" +[shell_environment_policy.filters] +"секрет_*" = "include" +"#, + ); + + merge_toml_values(&mut base, &overlay); + + assert_eq!(base, overlay); +} + +#[test] +fn shell_environment_policy_filters_replace_lower_legacy_filter_fields() { + let mut base = parse_toml( + r#" +[shell_environment_policy] +inherit = "core" +exclude = ["FLIP_TO_INCLUDE", "KEEP_EXCLUDED"] +include_only = ["FLIP_TO_EXCLUDE", "KEEP_INCLUDED"] +"#, + ); + let overlay = parse_toml( + r#" +[shell_environment_policy.filters] +"ADD_INCLUDED" = "include" +"FLIP_TO_EXCLUDE" = "exclude" +"FLIP_TO_INCLUDE" = "include" +"#, + ); + + merge_toml_values(&mut base, &overlay); + + assert_eq!( + base, + parse_toml( + r#" +[shell_environment_policy] +inherit = "core" + +[shell_environment_policy.filters] +"ADD_INCLUDED" = "include" +"FLIP_TO_EXCLUDE" = "exclude" +"FLIP_TO_INCLUDE" = "include" +"#, + ) + ); +} + +#[test] +fn shell_environment_policy_legacy_arrays_replace_lower_filters() { + let mut base = parse_toml( + r#" +[shell_environment_policy] +inherit = "core" + +[shell_environment_policy.filters] +"FLIP_TO_EXCLUDE" = "include" +"LOW_EXCLUDED" = "exclude" +"KEEP_INCLUDED" = "include" +"#, + ); + let overlay = parse_toml( + r#" +[shell_environment_policy] +exclude = ["FLIP_TO_EXCLUDE", "HIGH_EXCLUDED"] +"#, + ); + + merge_toml_values(&mut base, &overlay); + + assert_eq!( + base, + parse_toml( + r#" +[shell_environment_policy] +inherit = "core" +exclude = ["FLIP_TO_EXCLUDE", "HIGH_EXCLUDED"] +"#, + ) + ); +} + +#[test] +fn empty_shell_environment_filter_representations_replace_the_other_form() { + let cases = [ + ( + r#"[shell_environment_policy] +exclude = ["AWS_*"] +include_only = ["PATH"] +"#, + r#"[shell_environment_policy.filters] +"#, + ), + ( + r#"[shell_environment_policy.filters] +"AWS_*" = "include" +"#, + r#"[shell_environment_policy] +exclude = [] +"#, + ), + ]; + + for (base, overlay) in cases { + let mut base = parse_toml(base); + let overlay = parse_toml(overlay); + + merge_toml_values(&mut base, &overlay); + + assert_eq!(base, overlay); + } +} diff --git a/codex-rs/config/src/model_provider_requirements_tests.rs b/codex-rs/config/src/model_provider_requirements_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0862dcfdee6e90ade6a9ae973161f66e93e825fe --- /dev/null +++ b/codex-rs/config/src/model_provider_requirements_tests.rs @@ -0,0 +1,99 @@ +//! Tests exact provider requirements across layers and config projections. + +use crate::ConfigLayerEntry; +use crate::ConfigLayerSource; +use crate::ConfigLayerStack; +use crate::ConfigRequirements; +use crate::ConfigRequirementsToml; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::compose_requirements; +use pretty_assertions::assert_eq; + +const REQUIRED: &str = r#" +model_provider = "gateway" +[model_providers.gateway] +name = "Managed gateway" +base_url = "https://gateway.example.test/v1" +requires_openai_auth = true +[model_providers.gateway.http_headers] +X-Managed = "yes" +"#; + +#[test] +fn higher_requirements_merge_provider_fields_and_nested_tables() -> anyhow::Result<()> { + let cloud_source = RequirementSource::EnterpriseManaged { + id: "req_1".into(), + name: "Gateway".into(), + }; + let requirements = compose_requirements([ + RequirementsLayerEntry::from_toml( + RequirementSource::Unknown, + r#" +model_provider = "other" +[model_providers.gateway] +name = "System gateway" +base_url = "https://old.example.test" +env_key = "OLD_KEY" +supports_websockets = true +[model_providers.gateway.http_headers] +X-Old = "no" +X-Managed = "old" +[model_providers.other] +name = "Other provider" +"#, + ), + RequirementsLayerEntry::from_toml(cloud_source.clone(), REQUIRED), + ])? + .expect("provider requirements must not be discarded"); + let normalized = ConfigRequirements::try_from(requirements.clone())?; + assert_eq!( + normalized + .model_provider + .as_ref() + .map(|requirement| &requirement.source), + Some(&cloud_source) + ); + let requirements = requirements.into_toml(); + let expected: ConfigRequirementsToml = toml::from_str( + r#" +model_provider = "gateway" +[model_providers.gateway] +name = "Managed gateway" +base_url = "https://gateway.example.test/v1" +requires_openai_auth = true +env_key = "OLD_KEY" +supports_websockets = true +[model_providers.gateway.http_headers] +X-Managed = "yes" +X-Old = "no" +[model_providers.other] +name = "Other provider" +"#, + )?; + assert_eq!(requirements, expected); + assert_eq!( + normalized + .model_providers + .map(|requirement| requirement.value), + requirements.model_providers + ); + Ok(()) +} + +#[test] +fn required_provider_definitions_are_validated_without_local_defaults() -> anyhow::Result<()> { + for invalid in [ + "[model_providers.gateway]\nbase_url = 'https://example.test'", + "[model_providers.openai]\nname = 'Reserved'", + "[model_providers.gateway]\nname = 'Gateway'\n[model_providers.gateway.aws]\nregion = 'us-east-1'", + ] { + let requirements = toml::from_str(invalid)?; + let local = + ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, toml::from_str(REQUIRED)?); + let error = ConfigLayerStack::new(vec![local], ConfigRequirements::default(), requirements) + .expect_err("local config cannot repair an invalid required provider"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } + Ok(()) +} diff --git a/codex-rs/config/src/overrides.rs b/codex-rs/config/src/overrides.rs new file mode 100644 index 0000000000000000000000000000000000000000..70d5fcd2bc5600266d54f87f31aba6e0671bc94b --- /dev/null +++ b/codex-rs/config/src/overrides.rs @@ -0,0 +1,99 @@ +use crate::merge::is_structured_feature_path; +use crate::merge::merge_toml_values; +use toml::Value as TomlValue; + +pub(crate) fn default_empty_table() -> TomlValue { + TomlValue::Table(Default::default()) +} + +pub fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue { + let mut root = default_empty_table(); + for (path, value) in cli_overrides { + apply_toml_override(&mut root, path, value.clone()); + } + root +} + +/// Apply a single dotted-path override onto a TOML value. +fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { + use toml::value::Table; + + let mut current = root; + let mut segments_iter = path.split('.').peekable(); + let mut traversed_segments = Vec::new(); + + while let Some(segment) = segments_iter.next() { + traversed_segments.push(segment); + let is_last = segments_iter.peek().is_none(); + + if is_last { + match current { + TomlValue::Table(table) => { + if is_structured_feature_path(&traversed_segments) + && let Some(existing) = table.get_mut(segment) + { + match (&mut *existing, &value) { + (TomlValue::Table(feature), TomlValue::Boolean(enabled)) => { + feature.insert("enabled".to_string(), TomlValue::Boolean(*enabled)); + return; + } + (TomlValue::Boolean(enabled), TomlValue::Table(_)) => { + *existing = TomlValue::Table(Table::from_iter([( + "enabled".to_string(), + TomlValue::Boolean(*enabled), + )])); + merge_toml_values(existing, &value); + return; + } + (TomlValue::Table(_), TomlValue::Table(_)) => { + merge_toml_values(existing, &value); + return; + } + ( + TomlValue::String(_) + | TomlValue::Integer(_) + | TomlValue::Float(_) + | TomlValue::Boolean(_) + | TomlValue::Datetime(_) + | TomlValue::Array(_) + | TomlValue::Table(_), + _, + ) => {} + } + } + table.insert(segment.to_string(), value); + } + _ => { + let mut table = Table::new(); + table.insert(segment.to_string(), value); + *current = TomlValue::Table(table); + } + } + return; + } + + match current { + TomlValue::Table(table) => { + current = table + .entry(segment.to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + if is_structured_feature_path(&traversed_segments) + && let TomlValue::Boolean(enabled) = current + { + *current = TomlValue::Table(Table::from_iter([( + "enabled".to_string(), + TomlValue::Boolean(*enabled), + )])); + } + } + _ => { + *current = TomlValue::Table(Table::new()); + if let TomlValue::Table(tbl) = current { + current = tbl + .entry(segment.to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + } + } + } +} diff --git a/codex-rs/config/src/path_context.rs b/codex-rs/config/src/path_context.rs new file mode 100644 index 0000000000000000000000000000000000000000..e570e8a339d81f4551a056625e88c4e0803f31b7 --- /dev/null +++ b/codex-rs/config/src/path_context.rs @@ -0,0 +1,115 @@ +//! Path facts for configuration parsing. Native callers and callers resolving +//! another platform use the same resolver after fact capture. + +use codex_utils_absolute_path::AbsolutePathBufGuard; +use codex_utils_path_uri::LegacyAppPathStringError; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; +use std::cell::RefCell; + +/// The owning environment's path convention, base directory, and home for configuration. +#[derive(Clone, Debug)] +pub struct ConfigPathContext { + convention: PathConvention, + base_dir: Option, + user_home_dir: Option, +} + +impl ConfigPathContext { + /// Supplies the grammar and directories of the environment owning the layer. + /// Relative paths require a base; home-relative paths require a home directory. + pub fn new( + convention: PathConvention, + base_dir: Option, + user_home_dir: Option, + ) -> Self { + Self { + convention, + base_dir, + user_home_dir, + } + } + + /// Returns the path grammar supplied by the owning environment. + pub fn convention(&self) -> PathConvention { + self.convention + } + + /// Resolves configuration text to a URI using the supplied directory and home facts. + pub fn resolve_path(&self, input: &str) -> Result { + PathUri::resolve_config_path_uri( + input, + self.convention, + self.base_dir.as_ref(), + self.user_home_dir.as_ref(), + ) + } + + /// Resolves against a supplied base using this context's grammar and home. + pub fn resolve_against( + &self, + input: &str, + base: &PathUri, + ) -> Result { + PathUri::resolve_config_path_uri( + input, + self.convention, + Some(base), + self.user_home_dir.as_ref(), + ) + } + + pub(crate) fn enter(&self) -> PathContextGuard { + PathContextGuard(PATH_CONTEXT.with(|current| current.replace(Some(self.clone())))) + } +} + +thread_local! { + static PATH_CONTEXT: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) struct PathContextGuard(Option); + +impl Drop for PathContextGuard { + fn drop(&mut self) { + PATH_CONTEXT.with(|current| *current.borrow_mut() = self.0.take()); + } +} + +pub(crate) fn convention() -> PathConvention { + PATH_CONTEXT.with(|current| { + current + .borrow() + .as_ref() + .map_or_else(PathConvention::native, |context| context.convention) + }) +} + +pub(crate) fn resolve(input: &str) -> Result { + let context = match PATH_CONTEXT.with(|current| current.borrow().clone()) { + Some(context) => context, + None => { + let convention = PathConvention::native(); + let base = AbsolutePathBufGuard::deserialization_base(std::path::Path::new(input)) + .map_err(str::to_owned)? + .map(PathUri::from_host_native_path) + .transpose() + .map_err(|error| error.to_string())?; + let home = convention + .home_relative_suffix(input) + .and_then(|_| AbsolutePathBufGuard::home_directory()) + .map(PathUri::from_host_native_path) + .transpose() + .map_err(|error| error.to_string())?; + ConfigPathContext::new(convention, base, home) + } + }; + context + .resolve_path(input) + .and_then(|path| path.to_config_path_string(context.convention())) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +#[path = "path_context_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/path_context_tests.rs b/codex-rs/config/src/path_context_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..cd78522e520f69eb78a9a5d731bcc84e54c91b72 --- /dev/null +++ b/codex-rs/config/src/path_context_tests.rs @@ -0,0 +1,255 @@ +//! Contracts for layer-owned denial facts, parser scoping, and policy conversion. + +use super::ConfigPathContext; +use crate::FilesystemConstraints; +use crate::FilesystemDenyReadPattern; +use crate::RequirementSource; +use crate::RequirementsLayerEntry; +use crate::compose_requirements_for_hostname; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxKind; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +#[test] +fn composition_applies_each_layers_base_and_home_to_policy() { + let native_home = tempdir().expect("native home"); + let first = RequirementsLayerEntry::from_toml( + RequirementSource::Unknown, + "[permissions.filesystem]\ndeny_read = ['private', '~/secret', 'private', 'C:\\', '\\\\server\\share\\', 'private/*.key']", + ) + .with_path_context(ConfigPathContext::new( + PathConvention::Windows, + Some(PathUri::parse("file:///C:/first").expect("first base")), + Some(PathUri::parse("file:///C:/Users/first").expect("first home")), + )); + let second = RequirementsLayerEntry::from_toml_value( + RequirementSource::Unknown, + toml::toml! { [permissions.filesystem] deny_read = ["private", "~/secret"] }.into(), + ) + .with_path_context(ConfigPathContext::new( + PathConvention::Windows, + Some(PathUri::parse("file:///D:/second").expect("second base")), + Some(PathUri::parse("file:///D:/Users/second").expect("second home")), + )); + let composed = AbsolutePathBufGuard::with_home_directory(native_home.path(), || { + compose_requirements_for_hostname([first, second], /*hostname*/ None) + .expect("compose layers") + .expect("requirements present") + }); + let constraints = FilesystemConstraints::from(composed.permissions.expect("permissions").value); + let mut expected = FileSystemSandboxPolicy { + kind: FileSystemSandboxKind::Restricted, + glob_scan_max_depth: Some(7), + entries: vec![FileSystemSandboxEntry::new( + PathUri::parse("file:///C:/allowed") + .expect("existing path") + .into(), + FileSystemAccessMode::Write, + )], + }; + let mut policy = expected.clone(); + expected.entries.extend( + [ + "file:///D:/second/private", + "file:///D:/Users/second/secret", + "file:///C:/first/private", + "file:///C:/Users/first/secret", + "file:///C:/", + "file://server/share", + ] + .map(|path| { + FileSystemSandboxEntry::new( + PathUri::parse(path).expect("expected denial").into(), + FileSystemAccessMode::Deny, + ) + }), + ); + expected.entries.push(FileSystemSandboxEntry::new( + FileSystemPath::GlobPattern { + pattern: r"C:\first\private/*.key".to_string(), + }, + FileSystemAccessMode::Deny, + )); + constraints + .apply_to_policy(&mut policy, PathConvention::Windows) + .expect("apply denials"); + constraints + .apply_to_policy(&mut policy, PathConvention::Windows) + .expect("deduplicate existing denials"); + assert_eq!(policy, expected); + assert!( + constraints + .apply_to_policy(&mut policy, PathConvention::Posix) + .is_err(), + "a foreign denial cannot be reinterpreted using the host convention", + ); + assert_eq!(policy, expected); +} + +#[test] +fn common_parser_uses_supplied_windows_drive_and_glob_separators() { + let context = ConfigPathContext::new( + PathConvention::Windows, + Some(PathUri::parse("file:///C:/base/cwd").expect("base")), + /*user_home_dir*/ None, + ); + let _guard = context.enter(); + for (input, expected) in [ + (r"C:private\*.key", r"C:\base\cwd\private/*.key"), + (r"D:\*.key", r"D:\/*.key"), + (r"\private\*.key", r"C:\private/*.key"), + ] { + assert_eq!( + FilesystemDenyReadPattern::from_input(input) + .expect("resolve pattern") + .as_str(), + expected, + ); + } +} + +#[test] +fn windows_denial_globs_reject_ambiguous_roots_and_streams() { + let context = ConfigPathContext::new( + PathConvention::Windows, + Some(PathUri::parse("file://server/share/base").expect("UNC base")), + /*user_home_dir*/ None, + ); + let _guard = context.enter(); + for input in [ + r"\\?\C:\private", + r"\\*\share\private", + r"D:*.key", + r"private\*\file:stream", + ] { + assert!( + FilesystemDenyReadPattern::from_input(input).is_err(), + "{input}" + ); + } +} + +#[test] +fn failed_nested_composition_restores_outer_context_and_then_native_resolution() { + let base = tempdir().expect("native base"); + let _native_guard = AbsolutePathBufGuard::new(base.path()); + let native = FilesystemDenyReadPattern::from_input("private"); + { + let outer = ConfigPathContext::new( + PathConvention::Posix, + Some(PathUri::parse("file:///outer").expect("outer base")), + /*user_home_dir*/ None, + ); + let _outer_guard = outer.enter(); + let inner = RequirementsLayerEntry::from_toml( + RequirementSource::Unknown, + "[permissions.filesystem]\ndeny_read = ['relative']", + ) + .with_path_context(ConfigPathContext::new( + PathConvention::Windows, + /*base_dir*/ None, + /*user_home_dir*/ None, + )); + assert!(compose_requirements_for_hostname([inner], /*hostname*/ None).is_err()); + assert_eq!( + FilesystemDenyReadPattern::from_input("private") + .expect("outer context restored") + .as_str(), + "/outer/private", + ); + } + assert_eq!(FilesystemDenyReadPattern::from_input("private"), native); +} + +#[test] +fn native_guard_and_supplied_native_facts_use_the_same_parser() { + let base = tempdir().expect("base"); + let home = tempdir().expect("home"); + let _base_guard = AbsolutePathBufGuard::new(base.path()); + AbsolutePathBufGuard::with_home_directory(home.path(), || { + let inputs = ["./private", "~/secret/*.txt"]; + let native = inputs.map(FilesystemDenyReadPattern::from_input); + let context = ConfigPathContext::new( + PathConvention::native(), + Some(PathUri::from_host_native_path(base.path()).expect("native base URI")), + Some(PathUri::from_host_native_path(home.path()).expect("native home URI")), + ); + let _context_guard = context.enter(); + assert_eq!(inputs.map(FilesystemDenyReadPattern::from_input), native); + }); +} + +#[test] +fn missing_supplied_home_does_not_fall_back_to_native_home() { + let home = tempdir().expect("native home"); + let layer = RequirementsLayerEntry::from_toml( + RequirementSource::Unknown, + "[permissions.filesystem]\ndeny_read = ['~/secret']", + ) + .with_path_context(ConfigPathContext::new( + PathConvention::Windows, + Some(PathUri::parse("file:///C:/base").expect("base")), + /*user_home_dir*/ None, + )); + AbsolutePathBufGuard::with_home_directory(home.path(), || { + assert!(compose_requirements_for_hostname([layer], /*hostname*/ None).is_err()); + }); +} + +#[test] +fn denial_resolution_rejects_glob_syntax_in_supplied_facts() { + for directory in ["file:///home/sam[1]", "file:///C:/Users/sam[1]"] { + let directory = PathUri::parse(directory).unwrap(); + let context = ConfigPathContext::new( + directory.infer_path_convention().unwrap(), + Some(directory.clone()), + Some(directory), + ); + // Literal denials also reject metacharacters introduced by path facts. + for input in [ + "private/*.key", + "~/private/*.key", + "private/key", + "~/private/key", + ] { + let layer = RequirementsLayerEntry::from_toml( + RequirementSource::Unknown, + format!("[permissions.filesystem]\ndeny_read = ['{input}']"), + ) + .with_path_context(context.clone()); + assert!(compose_requirements_for_hostname([layer], /*hostname*/ None).is_err()); + } + } +} + +#[test] +fn native_and_supplied_denials_reject_the_same_unsafe_facts() { + let parent = tempdir().unwrap(); + let directory = parent.path().join("sam[1]"); + let _base_guard = AbsolutePathBufGuard::new(&directory); + AbsolutePathBufGuard::with_home_directory(&directory, || { + let inputs = [ + "private/*.key", + "~/private/*.key", + "private/key", + "~/private/key", + ]; + let native = inputs.map(FilesystemDenyReadPattern::from_input); + assert!(native.iter().all(Result::is_err)); + let directory = PathUri::from_host_native_path(&directory).unwrap(); + let context = ConfigPathContext::new( + PathConvention::native(), + Some(directory.clone()), + Some(directory), + ); + let _context_guard = context.enter(); + assert_eq!(inputs.map(FilesystemDenyReadPattern::from_input), native); + }); +} diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs new file mode 100644 index 0000000000000000000000000000000000000000..98fd3534d62b304b0bee37810386c7f94e90e20b --- /dev/null +++ b/codex-rs/config/src/permissions_toml.rs @@ -0,0 +1,600 @@ +use std::collections::BTreeMap; + +use crate::merge::merge_toml_values; +use codex_network_proxy::InjectedHeaderConfig; +use codex_network_proxy::MitmHookActionsConfig; +use codex_network_proxy::MitmHookBodyConfig; +use codex_network_proxy::MitmHookConfig; +use codex_network_proxy::MitmHookMatchConfig; +use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; +use codex_network_proxy::NetworkMode; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; +use codex_network_proxy::normalize_host; +use codex_protocol::permissions::FileSystemAccessMode; +use indexmap::IndexMap; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; +use toml::Value as TomlValue; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct PermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl PermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Resolve `profile_name` and all of its `extends` ancestors into one TOML + /// profile. + /// + /// Parent profiles are merged before their children, so child keys override + /// matching parent keys before callers compile the profile into runtime + /// permissions. The returned profile keeps the selected profile's + /// declaration metadata, such as `description` and `extends`. + pub fn resolve_profile( + &self, + profile_name: &str, + mut parent_profile: F, + ) -> Result + where + F: FnMut(&str) -> Option, + { + let mut profile_names = Vec::new(); + let mut profiles = Vec::new(); + let mut next_profile_name = profile_name.to_string(); + let mut referenced_by: Option = None; + + loop { + if let Some(cycle_start) = profile_names + .iter() + .position(|name| name == &next_profile_name) + { + let cycle = profile_names[cycle_start..] + .iter() + .cloned() + .chain(std::iter::once(next_profile_name)) + .collect::>(); + return Err(PermissionProfileResolutionError::Cycle { cycle }); + } + + let profile = self + .entries + .get(&next_profile_name) + .cloned() + .or_else(|| parent_profile(&next_profile_name)) + .ok_or_else(|| { + referenced_by.as_deref().map_or_else( + || PermissionProfileResolutionError::UndefinedProfile { + profile_name: next_profile_name.clone(), + }, + |referenced_by| { + if next_profile_name.starts_with(':') { + PermissionProfileResolutionError::UnsupportedBuiltInParent { + profile_name: referenced_by.to_string(), + parent_profile_name: next_profile_name.clone(), + } + } else { + PermissionProfileResolutionError::UndefinedParent { + profile_name: referenced_by.to_string(), + parent_profile_name: next_profile_name.clone(), + } + } + }, + ) + })?; + let parent_profile_name = profile.extends.clone(); + + profile_names.push(next_profile_name.clone()); + + if let Some(parent_profile_name) = parent_profile_name { + profiles.push(profile); + referenced_by = Some(next_profile_name); + next_profile_name = parent_profile_name; + continue; + } + + let profile = profiles + .into_iter() + .rev() + .try_fold(profile, merge_permission_profiles)?; + return Ok(profile); + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PermissionProfileToml { + pub description: Option, + pub extends: Option, + pub workspace_roots: Option, + pub filesystem: Option, + pub network: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PermissionProfileResolutionError { + #[error("default_permissions refers to undefined profile `{profile_name}`")] + UndefinedProfile { profile_name: String }, + #[error( + "permissions profile `{profile_name}` extends undefined profile `{parent_profile_name}`" + )] + UndefinedParent { + profile_name: String, + parent_profile_name: String, + }, + #[error( + "permissions profile `{profile_name}` cannot extend unsupported built-in profile `{parent_profile_name}`" + )] + UnsupportedBuiltInParent { + profile_name: String, + parent_profile_name: String, + }, + #[error( + "permissions profile inheritance cycle detected: {}", + cycle.join(" -> ") + )] + Cycle { cycle: Vec }, + #[error("failed to serialize permissions profile while resolving inheritance: {source}")] + SerializeProfileToml { + #[source] + source: toml::ser::Error, + }, + #[error( + "failed to deserialize merged permissions profile while resolving inheritance: {source}" + )] + DeserializeProfileToml { + #[source] + source: toml::de::Error, + }, +} + +fn merge_permission_profiles( + mut parent: PermissionProfileToml, + mut child: PermissionProfileToml, +) -> Result { + let merges_network_domains = parent + .network + .as_ref() + .and_then(|network| network.domains.as_ref()) + .is_some() + && child + .network + .as_ref() + .and_then(|network| network.domains.as_ref()) + .is_some(); + + // Description and inheritance metadata belong to the selected profile + // declaration, so an inherited profile must not fill those gaps. + parent.description = None; + parent.extends = None; + + if merges_network_domains { + normalize_profile_network_domains(&mut parent); + normalize_profile_network_domains(&mut child); + } + + let mut merged = TomlValue::try_from(parent) + .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?; + let child = TomlValue::try_from(child) + .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?; + merge_toml_values(&mut merged, &child); + merged + .try_into() + .map_err(|source| PermissionProfileResolutionError::DeserializeProfileToml { source }) +} + +fn normalize_profile_network_domains(profile: &mut PermissionProfileToml) { + let Some(domains) = profile + .network + .as_mut() + .and_then(|network| network.domains.as_mut()) + else { + return; + }; + + let entries = std::mem::take(&mut domains.entries); + domains.entries = entries + .into_iter() + .map(|(pattern, permission)| (normalize_host(&pattern), permission)) + .collect(); +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct WorkspaceRootsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl WorkspaceRootsToml { + pub fn enabled_roots(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|(path, enabled)| (*enabled).then_some(path)) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct FilesystemPermissionsToml { + /// Optional maximum depth for expanding unreadable glob patterns on + /// platforms that snapshot glob matches before sandbox startup. + #[schemars(range(min = 1))] + pub glob_scan_max_depth: Option, + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl FilesystemPermissionsToml { + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum FilesystemPermissionToml { + Access(FileSystemAccessMode), + Scoped(BTreeMap), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkDomainPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkDomainPermissionsToml { + pub fn allowed_domains(&self) -> Option> { + let allowed_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!allowed_domains.is_empty()).then_some(allowed_domains) + } + + pub fn denied_domains(&self) -> Option> { + let denied_domains: Vec = self + .entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) + .map(|(pattern, _)| pattern.clone()) + .collect(); + (!denied_domains.is_empty()).then_some(denied_domains) + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkDomainPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkDomainPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct NetworkUnixSocketPermissionsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl NetworkUnixSocketPermissionsToml { + pub fn allow_unix_sockets(&self) -> Vec { + self.entries + .iter() + .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) + .map(|(path, _)| path.clone()) + .collect() + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum NetworkUnixSocketPermissionToml { + Allow, + Deny, +} + +impl std::fmt::Display for NetworkUnixSocketPermissionToml { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let permission = match self { + Self::Allow => "allow", + Self::Deny => "deny", + }; + f.write_str(permission) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkToml { + pub enabled: Option, + pub proxy_url: Option, + pub enable_socks5: Option, + pub socks_url: Option, + pub enable_socks5_udp: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + #[schemars(with = "Option")] + pub mode: Option, + pub domains: Option, + pub unix_sockets: Option, + pub allow_local_binding: Option, + pub mitm: Option, +} + +#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkMitmToml { + #[schemars(with = "Option>")] + pub hooks: Option>, + #[schemars(with = "Option>")] + pub actions: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkMitmTomlUnchecked { + pub hooks: Option>, + pub actions: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkMitmHookToml { + pub host: String, + pub methods: Vec, + pub path_prefixes: Vec, + #[serde(default)] + pub query: BTreeMap>, + #[serde(default)] + pub headers: BTreeMap>, + #[schemars(with = "Option")] + pub body: Option, + pub action: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +enum NetworkModeSchema { + Limited, + Full, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +pub struct NetworkMitmActionToml { + pub strip_request_headers: Vec, + pub inject_request_headers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +pub struct NetworkMitmInjectedHeaderToml { + pub name: String, + pub secret_env_var: Option, + pub secret_file: Option, + pub prefix: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +struct MitmHookBodyConfigSchema(pub serde_json::Value); + +impl<'de> Deserialize<'de> for NetworkMitmToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?; + let mitm = Self { + hooks: unchecked.hooks, + actions: unchecked.actions, + }; + mitm.validate_action_definitions() + .map_err(serde::de::Error::custom)?; + Ok(mitm) + } +} + +impl NetworkMitmToml { + pub fn validate_action_definitions(&self) -> Result<(), String> { + if let Some(actions) = self.actions.as_ref() { + for (action_name, action) in actions { + if action.is_empty() { + return Err(format!( + "network.mitm.actions.{action_name} must define at least one operation" + )); + } + } + } + + let Some(hooks) = self.hooks.as_ref() else { + return Ok(()); + }; + + for (hook_name, hook) in hooks { + if hook.action.is_empty() { + return Err(format!( + "network.mitm.hooks.{hook_name}.action must not be empty" + )); + } + } + + Ok(()) + } + + pub fn to_runtime_hooks( + &self, + actions_by_name: Option<&IndexMap>, + ) -> Vec { + self.hooks + .as_ref() + .map(|hooks| { + hooks + .values() + .map(|hook| hook.to_runtime(actions_by_name)) + .collect() + }) + .unwrap_or_default() + } +} + +impl NetworkMitmActionToml { + pub fn is_empty(&self) -> bool { + self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty() + } +} + +impl NetworkToml { + pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { + if let Some(enabled) = self.enabled { + config.enabled = enabled; + } + if let Some(proxy_url) = self.proxy_url.as_ref() { + config.proxy_url = proxy_url.clone(); + } + if let Some(enable_socks5) = self.enable_socks5 { + config.enable_socks5 = enable_socks5; + } + if let Some(socks_url) = self.socks_url.as_ref() { + config.socks_url = socks_url.clone(); + } + if let Some(enable_socks5_udp) = self.enable_socks5_udp { + config.enable_socks5_udp = enable_socks5_udp; + } + if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { + config.allow_upstream_proxy = allow_upstream_proxy; + } + if let Some(dangerously_allow_non_loopback_proxy) = + self.dangerously_allow_non_loopback_proxy + { + config.dangerously_allow_non_loopback_proxy = dangerously_allow_non_loopback_proxy; + } + if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { + config.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; + } + if let Some(mode) = self.mode { + config.mode = mode; + } + if let Some(domains) = self.domains.as_ref() { + overlay_network_domain_permissions(config, domains); + } + if let Some(unix_sockets) = self.unix_sockets.as_ref() { + let mut proxy_unix_sockets = config.unix_sockets.take().unwrap_or_default(); + for (path, permission) in &unix_sockets.entries { + let permission = match permission { + NetworkUnixSocketPermissionToml::Allow => { + ProxyNetworkUnixSocketPermission::Allow + } + NetworkUnixSocketPermissionToml::Deny => ProxyNetworkUnixSocketPermission::Deny, + }; + proxy_unix_sockets.entries.insert(path.clone(), permission); + } + config.unix_sockets = + (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets); + } + if let Some(allow_local_binding) = self.allow_local_binding { + config.allow_local_binding = allow_local_binding; + } + if let Some(mitm) = self.mitm.as_ref() { + config.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref()); + } + config.mitm = config.mode == NetworkMode::Limited || !config.mitm_hooks.is_empty(); + } + + pub fn to_network_proxy_config(&self) -> NetworkProxyConfig { + let mut config = NetworkProxyConfig::default(); + self.apply_to_network_proxy_config(&mut config); + config + } +} + +impl NetworkMitmHookToml { + fn to_runtime( + &self, + actions_by_name: Option<&IndexMap>, + ) -> MitmHookConfig { + MitmHookConfig { + host: self.host.clone(), + matcher: MitmHookMatchConfig { + methods: self.methods.clone(), + path_prefixes: self.path_prefixes.clone(), + query: self.query.clone(), + headers: self.headers.clone(), + body: self.body.clone(), + }, + actions: self.selected_actions(actions_by_name), + } + } + + fn selected_actions( + &self, + actions_by_name: Option<&IndexMap>, + ) -> MitmHookActionsConfig { + let Some(actions_by_name) = actions_by_name else { + return MitmHookActionsConfig::default(); + }; + + let mut selected = MitmHookActionsConfig::default(); + for action_name in &self.action { + if let Some(action) = actions_by_name.get(action_name) { + selected + .strip_request_headers + .extend(action.strip_request_headers.clone()); + selected.inject_request_headers.extend( + action + .inject_request_headers + .iter() + .map(NetworkMitmInjectedHeaderToml::to_runtime), + ); + } + } + selected + } +} + +impl NetworkMitmInjectedHeaderToml { + fn to_runtime(&self) -> InjectedHeaderConfig { + InjectedHeaderConfig { + name: self.name.clone(), + secret_env_var: self.secret_env_var.clone(), + secret_file: self.secret_file.clone(), + prefix: self.prefix.clone(), + } + } +} + +pub fn overlay_network_domain_permissions( + config: &mut NetworkProxyConfig, + domains: &NetworkDomainPermissionsToml, +) { + for (pattern, permission) in &domains.entries { + let permission = match permission { + NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow, + NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny, + }; + config.upsert_domain_permission(pattern.clone(), permission, normalize_host); + } +} diff --git a/codex-rs/config/src/project_root_markers.rs b/codex-rs/config/src/project_root_markers.rs new file mode 100644 index 0000000000000000000000000000000000000000..3061dacc6b793cc33265a81ed704d8369240bbfc --- /dev/null +++ b/codex-rs/config/src/project_root_markers.rs @@ -0,0 +1,50 @@ +use std::io; + +use toml::Value as TomlValue; + +const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; + +/// Reads `project_root_markers` from a merged `config.toml` [toml::Value]. +/// +/// Invariants: +/// - If `project_root_markers` is not specified, returns `Ok(None)`. +/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where +/// `markers` is a `Vec` (including `Ok(Some(Vec::new()))` for an +/// empty array, which indicates that root detection should be disabled). +/// - Returns an error if `project_root_markers` is specified but is not an +/// array of strings. +pub fn project_root_markers_from_config(config: &TomlValue) -> io::Result>> { + let Some(table) = config.as_table() else { + return Ok(None); + }; + let Some(markers_value) = table.get("project_root_markers") else { + return Ok(None); + }; + let TomlValue::Array(entries) = markers_value else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "project_root_markers must be an array of strings", + )); + }; + if entries.is_empty() { + return Ok(Some(Vec::new())); + } + let mut markers = Vec::new(); + for entry in entries { + let Some(marker) = entry.as_str() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "project_root_markers must be an array of strings", + )); + }; + markers.push(marker.to_string()); + } + Ok(Some(markers)) +} + +pub fn default_project_root_markers() -> Vec { + DEFAULT_PROJECT_ROOT_MARKERS + .iter() + .map(ToString::to_string) + .collect() +} diff --git a/codex-rs/config/src/requirements_exec_policy.rs b/codex-rs/config/src/requirements_exec_policy.rs new file mode 100644 index 0000000000000000000000000000000000000000..4bdd425236c9f107cc7f092b2ebe5e94e626564e --- /dev/null +++ b/codex-rs/config/src/requirements_exec_policy.rs @@ -0,0 +1,201 @@ +use codex_execpolicy::Decision; +use codex_execpolicy::Policy; +use codex_execpolicy::RequirementsExecPolicy; +use codex_execpolicy::RuleRef; +use codex_execpolicy::rule::PatternToken; +use codex_execpolicy::rule::PrefixPattern; +use codex_execpolicy::rule::PrefixRule; +use multimap::MultiMap; +use serde::Deserialize; +use std::sync::Arc; +use thiserror::Error; + +/// TOML representation of `[rules]` within `requirements.toml`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyToml { + pub prefix_rules: Vec, +} + +/// A TOML representation of the `prefix_rule(...)` Starlark builtin. +/// +/// This mirrors the builtin defined in `execpolicy/src/parser.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyPrefixRuleToml { + pub pattern: Vec, + pub decision: Option, + pub justification: Option, +} + +/// TOML-friendly representation of a pattern token. +/// +/// Starlark supports either a string token or a list of alternative tokens at +/// each position, but TOML arrays cannot mix strings and arrays. Using an +/// array of tables sidesteps that restriction. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RequirementsExecPolicyPatternTokenToml { + pub token: Option, + pub any_of: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RequirementsExecPolicyDecisionToml { + Allow, + Prompt, + Forbidden, +} + +impl RequirementsExecPolicyDecisionToml { + fn as_decision(self) -> Decision { + match self { + Self::Allow => Decision::Allow, + Self::Prompt => Decision::Prompt, + Self::Forbidden => Decision::Forbidden, + } + } +} + +#[derive(Debug, Error)] +pub enum RequirementsExecPolicyParseError { + #[error("rules prefix_rules cannot be empty")] + EmptyPrefixRules, + + #[error("rules prefix_rule at index {rule_index} has an empty pattern")] + EmptyPattern { rule_index: usize }, + + #[error( + "rules prefix_rule at index {rule_index} has an invalid pattern token at index {token_index}: {reason}" + )] + InvalidPatternToken { + rule_index: usize, + token_index: usize, + reason: String, + }, + + #[error("rules prefix_rule at index {rule_index} has an empty justification")] + EmptyJustification { rule_index: usize }, + + #[error("rules prefix_rule at index {rule_index} is missing a decision")] + MissingDecision { rule_index: usize }, + + #[error( + "rules prefix_rule at index {rule_index} has decision 'allow', which is not permitted in requirements.toml: Codex merges these rules with other config and uses the most restrictive result (use 'prompt' or 'forbidden')" + )] + AllowDecisionNotAllowed { rule_index: usize }, +} + +impl RequirementsExecPolicyToml { + /// Convert requirements TOML rules into the internal `.rules` + /// representation used by `codex-execpolicy`. + pub fn to_policy(&self) -> Result { + if self.prefix_rules.is_empty() { + return Err(RequirementsExecPolicyParseError::EmptyPrefixRules); + } + + let mut rules_by_program: MultiMap = MultiMap::new(); + + for (rule_index, rule) in self.prefix_rules.iter().enumerate() { + if let Some(justification) = &rule.justification + && justification.trim().is_empty() + { + return Err(RequirementsExecPolicyParseError::EmptyJustification { rule_index }); + } + + if rule.pattern.is_empty() { + return Err(RequirementsExecPolicyParseError::EmptyPattern { rule_index }); + } + + let pattern_tokens = rule + .pattern + .iter() + .enumerate() + .map(|(token_index, token)| parse_pattern_token(token, rule_index, token_index)) + .collect::, _>>()?; + + let decision = match rule.decision { + Some(RequirementsExecPolicyDecisionToml::Allow) => { + return Err(RequirementsExecPolicyParseError::AllowDecisionNotAllowed { + rule_index, + }); + } + Some(decision) => decision.as_decision(), + None => { + return Err(RequirementsExecPolicyParseError::MissingDecision { rule_index }); + } + }; + let justification = rule.justification.clone(); + + let (first_token, remaining_tokens) = pattern_tokens + .split_first() + .ok_or(RequirementsExecPolicyParseError::EmptyPattern { rule_index })?; + + let rest: Arc<[PatternToken]> = remaining_tokens.to_vec().into(); + + for head in first_token.alternatives() { + let rule: RuleRef = Arc::new(PrefixRule { + pattern: PrefixPattern { + first: Arc::from(head.as_str()), + rest: rest.clone(), + }, + decision, + justification: justification.clone(), + }); + rules_by_program.insert(head.clone(), rule); + } + } + + Ok(Policy::new(rules_by_program)) + } + + pub(crate) fn to_requirements_policy( + &self, + ) -> Result { + self.to_policy().map(RequirementsExecPolicy::new) + } +} + +fn parse_pattern_token( + token: &RequirementsExecPolicyPatternTokenToml, + rule_index: usize, + token_index: usize, +) -> Result { + match (&token.token, &token.any_of) { + (Some(single), None) => { + if single.trim().is_empty() { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "token cannot be empty".to_string(), + }); + } + Ok(PatternToken::Single(single.clone())) + } + (None, Some(alternatives)) => { + if alternatives.is_empty() { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "any_of cannot be empty".to_string(), + }); + } + if alternatives.iter().any(|alt| alt.trim().is_empty()) { + return Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "any_of cannot include empty tokens".to_string(), + }); + } + Ok(PatternToken::Alts(alternatives.clone())) + } + (Some(_), Some(_)) => Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "set either token or any_of, not both".to_string(), + }), + (None, None) => Err(RequirementsExecPolicyParseError::InvalidPatternToken { + rule_index, + token_index, + reason: "set either token or any_of".to_string(), + }), + } +} diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs new file mode 100644 index 0000000000000000000000000000000000000000..fd144796ee2dcb8d4b91143d7f96083ade4e35f4 --- /dev/null +++ b/codex-rs/config/src/schema.rs @@ -0,0 +1,292 @@ +use crate::config_toml::ConfigToml; +use crate::types::RawMcpServerConfig; +use codex_features::FEATURES; +use codex_features::legacy_feature_keys; +use codex_protocol::protocol::GranularApprovalConfig; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::r#gen::SchemaSettings; +use schemars::schema::InstanceType; +use schemars::schema::ObjectValidation; +use schemars::schema::RootSchema; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use schemars::schema::SubschemaValidation; +use serde_json::Map; +use serde_json::Value; +use std::path::Path; + +/// Determines the conditions under which the user is consulted to approve +/// running the command proposed by Codex. +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(rename = "AskForApproval")] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ConfigAskForApproval { + /// The model decides when to ask the user for approval. + OnRequest, + + /// Fine-grained controls for individual approval flows. + /// + /// When a field is `true`, commands in that category are allowed. When it + /// is `false`, those requests are automatically rejected instead of shown + /// to the user. + Granular(GranularApprovalConfig), + + /// Never ask the user to approve commands. Failures are immediately returned + /// to the model, and never escalated to the user for approval. + Never, +} + +/// Schema for the `[features]` map with known + legacy keys only. +pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let mut validation = ObjectValidation::default(); + for feature in FEATURES { + if feature.id == codex_features::Feature::Artifact { + continue; + } + if feature.id == codex_features::Feature::CodeMode { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::CodeModeHost { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::NonPrefixedMcpToolNames { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::GuardianThreadContext { + // This setting is already part of the guardianv2 feature table. + continue; + } + if feature.id == codex_features::Feature::GuardianV2 { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::MultiAgentV2 { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::TokenBudget { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::ContextManagement { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::RolloutBudget { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::CurrentTimeReminder { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::SleepTool { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + if feature.id == codex_features::Feature::AppsMcpPathOverride { + validation.properties.insert( + feature.key.to_string(), + removed_apps_mcp_path_override_schema(schema_gen), + ); + continue; + } + if feature.id == codex_features::Feature::NetworkProxy { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } + validation + .properties + .insert(feature.key.to_string(), schema_gen.subschema_for::()); + } + for legacy_key in legacy_feature_keys() { + validation + .properties + .insert(legacy_key.to_string(), schema_gen.subschema_for::()); + } + validation.properties.insert( + "tool_registry".to_string(), + schema_gen.subschema_for::(), + ); + validation.additional_properties = Some(Box::new(Schema::Bool(false))); + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +fn removed_apps_mcp_path_override_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut config_validation = ObjectValidation::default(); + config_validation + .properties + .insert("enabled".to_string(), schema_gen.subschema_for::()); + config_validation + .properties + .insert("path".to_string(), schema_gen.subschema_for::()); + config_validation.additional_properties = Some(Box::new(Schema::Bool(false))); + + let config = Schema::Object(SchemaObject { + instance_type: Some(InstanceType::Object.into()), + object: Some(Box::new(config_validation)), + ..Default::default() + }); + Schema::Object(SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + any_of: Some(vec![schema_gen.subschema_for::(), config]), + ..Default::default() + })), + ..Default::default() + }) +} + +/// Schema for the `[mcp_servers]` map using the raw input shape. +pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema { + let mut object = SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }; + + let validation = ObjectValidation { + additional_properties: Some(Box::new(schema_gen.subschema_for::())), + ..Default::default() + }; + object.object = Some(Box::new(validation)); + + Schema::Object(object) +} + +/// Build the config schema for `config.toml`. +pub fn config_schema() -> RootSchema { + let mut schema = SchemaSettings::draft07() + .with(|settings| { + settings.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::(); + add_shell_environment_policy_constraints(&mut schema); + schema +} + +fn add_shell_environment_policy_constraints(schema: &mut RootSchema) { + let Some(Schema::Object(policy)) = schema.definitions.get_mut("ShellEnvironmentPolicyToml") + else { + return; + }; + let all_of = policy + .subschemas + .get_or_insert_default() + .all_of + .get_or_insert_default(); + for fields in [["exclude", "filters"], ["filters", "include_only"]] { + all_of.push(Schema::Object(SchemaObject { + subschemas: Some(Box::new(SubschemaValidation { + not: Some(Box::new(Schema::Object(SchemaObject { + object: Some(Box::new(ObjectValidation { + required: fields.into_iter().map(str::to_string).collect(), + ..Default::default() + })), + ..Default::default() + }))), + ..Default::default() + })), + ..Default::default() + })); + } +} + +/// Canonicalize a JSON value by sorting its keys. +pub fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + Value::Object(map) => { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by_key(|(key, _)| *key); + let mut sorted = Map::with_capacity(map.len()); + for (key, child) in entries { + sorted.insert(key.clone(), canonicalize(child)); + } + Value::Object(sorted) + } + _ => value.clone(), + } +} + +/// Render the config schema as pretty-printed JSON. +pub fn config_schema_json() -> anyhow::Result> { + let schema = config_schema(); + let value = serde_json::to_value(schema)?; + let value = canonicalize(&value); + let json = serde_json::to_vec_pretty(&value)?; + Ok(json) +} + +/// Write the config schema fixture to disk. +pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { + let json = config_schema_json()?; + std::fs::write(out_path, json)?; + Ok(()) +} diff --git a/codex-rs/config/src/skills_config.rs b/codex-rs/config/src/skills_config.rs new file mode 100644 index 0000000000000000000000000000000000000000..cc944d817c1a65c8424e069b5a7cd466646f84bf --- /dev/null +++ b/codex-rs/config/src/skills_config.rs @@ -0,0 +1,215 @@ +//! Skill-related configuration types shared across crates. + +use std::collections::HashSet; +use std::num::NonZeroUsize; + +use crate::ConfigLayerSource; +use crate::ConfigLayerStack; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use tracing::warn; + +const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillConfig { + /// Path-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Name-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillsConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundled: Option, + + /// Whether turns receive the automatic skills instructions block. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_instructions: Option, + + /// Maximum tokens used by the available-skills catalog. Defaults to 2% of + /// the model context window and is capped at 10,000 tokens when set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_context_tokens: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct BundledSkillsConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +impl Default for BundledSkillsConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +impl TryFrom for SkillsConfig { + type Error = toml::de::Error; + + fn try_from(value: toml::Value) -> Result { + SkillsConfig::deserialize(value) + } +} + +/// Selects configured skills by their name or canonical document path. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SkillConfigRuleSelector { + Name(String), + Path(AbsolutePathBuf), +} + +/// Enables or disables every skill matched by its selector. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SkillConfigRule { + pub selector: SkillConfigRuleSelector, + pub enabled: bool, +} + +/// Ordered effective skill enablement rules from configuration layers. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct SkillConfigRules { + pub entries: Vec, +} + +impl SkillConfigRules { + /// Applies rules in order; later rules override earlier rules for matching skills. + /// + /// Explicit path selectors remain effective even when no current skill matches. + pub fn resolve_disabled_paths<'a>( + &self, + skills: impl IntoIterator + Clone, + ) -> HashSet { + let mut disabled_paths = HashSet::new(); + + for entry in &self.entries { + match &entry.selector { + SkillConfigRuleSelector::Path(path) => { + if entry.enabled { + disabled_paths.remove(path); + } else { + disabled_paths.insert(path.clone()); + } + } + SkillConfigRuleSelector::Name(name) => { + for (skill_name, path) in skills.clone() { + if skill_name != name { + continue; + } + if entry.enabled { + disabled_paths.remove(path); + } else { + disabled_paths.insert(path.clone()); + } + } + } + } + } + + disabled_paths + } +} + +/// Returns whether bundled skills are enabled by the effective configuration. +pub fn bundled_skills_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool { + let effective_config = config_layer_stack.effective_config(); + let Some(skills_value) = effective_config + .as_table() + .and_then(|table| table.get("skills")) + else { + return true; + }; + + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + return true; + } + }; + + skills.bundled.unwrap_or_default().enabled +} + +/// Resolves skill enablement rules from user and session configuration layers. +pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> SkillConfigRules { + let mut entries = Vec::new(); + for layer in config_layer_stack.all_layers_low_to_high() { + if !matches!( + layer.name, + ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags + ) { + continue; + } + + let Some(skills_value) = layer.config.get("skills") else { + continue; + }; + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + continue; + } + }; + + for entry in skills.config { + let Some(selector) = skill_config_rule_selector(&entry) else { + continue; + }; + // Preserve layer order so a later name selector can override an earlier path selector + // for the same loaded skill. + entries.retain(|entry: &SkillConfigRule| entry.selector != selector); + entries.push(SkillConfigRule { + selector, + enabled: entry.enabled, + }); + } + } + + SkillConfigRules { entries } +} + +fn skill_config_rule_selector(entry: &SkillConfig) -> Option { + match (entry.path.as_ref(), entry.name.as_deref()) { + (Some(path), None) => Some(SkillConfigRuleSelector::Path( + path.canonicalize().unwrap_or_else(|_| path.clone()), + )), + (None, Some(name)) => { + let name = name.trim(); + if name.is_empty() { + warn!("ignoring empty skills.config name override"); + None + } else { + Some(SkillConfigRuleSelector::Name(name.to_string())) + } + } + (Some(_), Some(_)) => { + warn!("ignoring skills.config entry with both path and name selectors"); + None + } + (None, None) => { + warn!("ignoring skills.config entry without a path or name selector"); + None + } + } +} + +#[cfg(test)] +#[path = "skills_config_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/skills_config_tests.rs b/codex-rs/config/src/skills_config_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..23cb3c5c5f5e883fba6c71794f70f00496d2b3c0 --- /dev/null +++ b/codex-rs/config/src/skills_config_tests.rs @@ -0,0 +1,242 @@ +use crate::CONFIG_TOML_FILE; +use crate::ConfigLayerEntry; +use crate::ConfigLayerSource; +use crate::ConfigLayerStack; +use crate::ConfigRequirementsToml; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +use super::SkillConfigRule; +use super::SkillConfigRuleSelector; +use super::SkillConfigRules; +use super::bundled_skills_enabled_from_stack; +use super::skill_config_rules_from_stack; + +fn user_layer(codex_home: &TempDir, config: &str) -> ConfigLayerEntry { + let config_path = AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE)) + .expect("absolute config path"); + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: config_path, + profile: None, + }, + toml::from_str(config).expect("valid user config"), + ) +} + +fn stack(codex_home: &TempDir, user: &str, session: &str) -> ConfigLayerStack { + ConfigLayerStack::new( + vec![ + user_layer(codex_home, user), + ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(session).expect("valid session config"), + ), + ], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config stack") +} + +fn path_toggle_config(path: &std::path::Path, enabled: bool) -> String { + let path = toml::Value::String(path.display().to_string()); + format!( + r#"[[skills.config]] +path = {path} +enabled = {enabled} +"# + ) +} + +#[test] +fn bundled_skills_follow_effective_configuration() { + let codex_home = TempDir::new().expect("temp dir"); + + assert!(bundled_skills_enabled_from_stack(&stack( + &codex_home, + "", + "" + ))); + assert!(!bundled_skills_enabled_from_stack(&stack( + &codex_home, + "[skills.bundled]\nenabled = false\n", + "" + ))); + assert!(bundled_skills_enabled_from_stack(&stack( + &codex_home, + "[skills.bundled]\nenabled = false\n", + "[skills.bundled]\nenabled = true\n" + ))); +} + +#[test] +fn malformed_bundled_skills_config_defaults_to_enabled() { + let codex_home = TempDir::new().expect("temp dir"); + + assert!(bundled_skills_enabled_from_stack(&stack( + &codex_home, + "[skills]\nbundled = 'invalid'\n", + "" + ))); +} + +#[test] +fn session_flags_can_reenable_user_disabled_path() { + let codex_home = TempDir::new().expect("temp dir"); + let skill_path = codex_home.path().join("skills/demo/SKILL.md"); + + assert_eq!( + skill_config_rules_from_stack(&stack( + &codex_home, + &path_toggle_config(&skill_path, /*enabled*/ false), + &path_toggle_config(&skill_path, /*enabled*/ true), + )), + SkillConfigRules { + entries: vec![SkillConfigRule { + selector: SkillConfigRuleSelector::Path(skill_path.abs()), + enabled: true, + }], + } + ); +} + +#[test] +fn session_flags_can_disable_user_enabled_path() { + let codex_home = TempDir::new().expect("temp dir"); + let skill_path = codex_home.path().join("skills/demo/SKILL.md"); + + assert_eq!( + skill_config_rules_from_stack(&stack( + &codex_home, + &path_toggle_config(&skill_path, /*enabled*/ true), + &path_toggle_config(&skill_path, /*enabled*/ false), + )), + SkillConfigRules { + entries: vec![SkillConfigRule { + selector: SkillConfigRuleSelector::Path(skill_path.abs()), + enabled: false, + }], + } + ); +} + +#[test] +fn preserves_name_selectors() { + let codex_home = TempDir::new().expect("temp dir"); + + assert_eq!( + skill_config_rules_from_stack(&stack( + &codex_home, + r#" +[[skills.config]] +name = "github:yeet" +enabled = false +"#, + "", + )), + SkillConfigRules { + entries: vec![SkillConfigRule { + selector: SkillConfigRuleSelector::Name("github:yeet".to_string()), + enabled: false, + }], + } + ); +} + +#[test] +fn preserves_order_across_path_and_name_selectors() { + let codex_home = TempDir::new().expect("temp dir"); + let skill_path = codex_home.path().join("skills/demo/SKILL.md"); + + assert_eq!( + skill_config_rules_from_stack(&stack( + &codex_home, + &path_toggle_config(&skill_path, /*enabled*/ false), + r#" +[[skills.config]] +name = "github:yeet" +enabled = true +"#, + )), + SkillConfigRules { + entries: vec![ + SkillConfigRule { + selector: SkillConfigRuleSelector::Path(skill_path.abs()), + enabled: false, + }, + SkillConfigRule { + selector: SkillConfigRuleSelector::Name("github:yeet".to_string()), + enabled: true, + }, + ], + } + ); +} + +#[test] +fn path_rule_disables_selected_path() { + let codex_home = TempDir::new().expect("temp dir"); + let path = codex_home.path().join("disable-by-path/SKILL.md").abs(); + let rules = SkillConfigRules { + entries: vec![SkillConfigRule { + selector: SkillConfigRuleSelector::Path(path.clone()), + enabled: false, + }], + }; + + assert_eq!( + rules.resolve_disabled_paths(std::iter::empty()), + [path].into_iter().collect() + ); +} + +#[test] +fn later_name_rule_reenables_path_disabled_skill() { + let codex_home = TempDir::new().expect("temp dir"); + let path = codex_home.path().join("reenable-by-name/SKILL.md").abs(); + let rules = SkillConfigRules { + entries: vec![ + SkillConfigRule { + selector: SkillConfigRuleSelector::Path(path.clone()), + enabled: false, + }, + SkillConfigRule { + selector: SkillConfigRuleSelector::Name("demo".to_string()), + enabled: true, + }, + ], + }; + + assert_eq!( + rules.resolve_disabled_paths([("demo", &path)]), + Default::default() + ); +} + +#[test] +fn later_path_rule_reenables_one_skill_disabled_by_name() { + let codex_home = TempDir::new().expect("temp dir"); + let root = codex_home.path().join("reenable-by-path"); + let first_path = root.join("first/SKILL.md").abs(); + let second_path = root.join("second/SKILL.md").abs(); + let rules = SkillConfigRules { + entries: vec![ + SkillConfigRule { + selector: SkillConfigRuleSelector::Name("demo".to_string()), + enabled: false, + }, + SkillConfigRule { + selector: SkillConfigRuleSelector::Path(first_path.clone()), + enabled: true, + }, + ], + }; + + assert_eq!( + rules.resolve_disabled_paths([("demo", &first_path), ("demo", &second_path)]), + [second_path].into_iter().collect() + ); +} diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs new file mode 100644 index 0000000000000000000000000000000000000000..57b5daff2b199439492f01570fa6890924c4e5fd --- /dev/null +++ b/codex-rs/config/src/state.rs @@ -0,0 +1,630 @@ +use crate::CONFIG_TOML_FILE; +use crate::config_requirements::ConfigRequirements; +use crate::config_requirements::ConfigRequirementsToml; +use crate::format_config_layer_source; + +use super::fingerprint::record_origins; +use super::fingerprint::version_for_toml; +use super::key_aliases::normalized_with_key_aliases; +use super::merge::merge_toml_values; +use crate::CloudConfigBundleLoader; +use crate::ConfigLayer; +use crate::ConfigLayerMetadata; +use crate::ConfigLayerSource; +use crate::ProfileV2Name; +use crate::shell_environment_policy::validate_shell_environment_policy_filter_config; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use toml::Value as TomlValue; + +/// User-facing config loading behavior that is not part of the config document. +#[derive(Debug, Default, Clone)] +pub struct ConfigLoadOptions { + pub loader_overrides: LoaderOverrides, + pub strict_config: bool, + pub cloud_config_bundle: CloudConfigBundleLoader, +} + +impl From for ConfigLoadOptions { + fn from(loader_overrides: LoaderOverrides) -> Self { + Self { + loader_overrides, + strict_config: false, + cloud_config_bundle: CloudConfigBundleLoader::default(), + } + } +} + +/// LoaderOverrides overrides managed configuration inputs (primarily for tests). +#[derive(Debug, Default, Clone)] +pub struct LoaderOverrides { + /// Optional configuration file supplied with the installed Codex package. + pub packaged_defaults_path: Option, + pub user_config_path: Option, + pub user_config_profile: Option, + pub managed_config_path: Option, + pub system_config_path: Option, + pub system_requirements_path: Option, + pub ignore_managed_requirements: bool, + /// Remote app servers own their authentication policy independently. + pub ignore_login_requirements: bool, + pub ignore_user_config: bool, + /// Skip project-root discovery and all project configuration layers. + pub ignore_project_config: bool, + pub ignore_user_and_project_exec_policy_rules: bool, + //TODO(gt): Add a macos_ prefix to this field and remove the target_os check. + #[cfg(target_os = "macos")] + pub managed_preferences_base64: Option, + pub macos_managed_config_requirements_base64: Option, +} + +impl LoaderOverrides { + /// Returns overrides that ignore host-managed configuration. + /// + /// This is intended for tests that should load only repo-controlled config fixtures. + pub fn without_managed_config_for_tests() -> Self { + let base = std::env::temp_dir().join("codex-config-tests"); + Self { + packaged_defaults_path: None, + user_config_path: None, + user_config_profile: None, + managed_config_path: Some(base.join("managed_config.toml")), + system_config_path: Some(base.join("config.toml")), + system_requirements_path: Some(base.join("requirements.toml")), + ignore_managed_requirements: false, + ignore_login_requirements: false, + ignore_user_config: false, + ignore_project_config: false, + ignore_user_and_project_exec_policy_rules: false, + #[cfg(target_os = "macos")] + managed_preferences_base64: Some(String::new()), + macos_managed_config_requirements_base64: Some(String::new()), + } + } + + /// Returns overrides with host MDM disabled and managed config loaded from + /// `managed_config_path`. System requirements are loaded from a sibling + /// `requirements.toml` fixture. + /// + /// This is intended for tests that supply an explicit managed config fixture. + pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self { + let system_requirements_path = managed_config_path.with_file_name("requirements.toml"); + Self { + user_config_path: None, + user_config_profile: None, + managed_config_path: Some(managed_config_path), + system_requirements_path: Some(system_requirements_path), + ..Self::without_managed_config_for_tests() + } + } + + pub fn user_config_path(&self, codex_home: &Path) -> std::io::Result { + match self.user_config_path.as_ref() { + Some(path) => Ok(path.clone()), + None => Ok(AbsolutePathBuf::resolve_path_against_base( + crate::CONFIG_TOML_FILE, + codex_home, + )), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConfigLayerEntry { + pub name: ConfigLayerSource, + pub config: TomlValue, + pub version: String, + pub disabled_reason: Option, + raw_toml: Option, + hooks_config_folder_override: Option, +} + +#[derive(Debug, Clone, PartialEq)] +struct RawTomlLayer { + contents: String, + base_dir: AbsolutePathBuf, +} + +impl ConfigLayerEntry { + pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + version, + disabled_reason: None, + raw_toml: None, + hooks_config_folder_override: None, + } + } + + pub fn new_with_raw_toml( + name: ConfigLayerSource, + config: TomlValue, + raw_toml: String, + raw_toml_base_dir: AbsolutePathBuf, + ) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + version, + disabled_reason: None, + raw_toml: Some(RawTomlLayer { + contents: raw_toml, + base_dir: raw_toml_base_dir, + }), + hooks_config_folder_override: None, + } + } + + pub fn new_disabled( + name: ConfigLayerSource, + config: TomlValue, + disabled_reason: impl Into, + ) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + version, + disabled_reason: Some(disabled_reason.into()), + raw_toml: None, + hooks_config_folder_override: None, + } + } + + pub fn is_disabled(&self) -> bool { + self.disabled_reason.is_some() + } + + pub fn raw_toml(&self) -> Option<&str> { + self.raw_toml + .as_ref() + .map(|raw_toml| raw_toml.contents.as_str()) + } + + pub fn raw_toml_base_dir(&self) -> Option<&AbsolutePathBuf> { + self.raw_toml.as_ref().map(|raw_toml| &raw_toml.base_dir) + } + + pub(crate) fn with_hooks_config_folder_override( + mut self, + hooks_config_folder_override: Option, + ) -> Self { + self.hooks_config_folder_override = hooks_config_folder_override; + self + } + + pub fn metadata(&self) -> ConfigLayerMetadata { + ConfigLayerMetadata { + name: self.name.clone(), + version: self.version.clone(), + } + } + + pub fn as_layer(&self) -> ConfigLayer { + ConfigLayer { + name: self.name.clone(), + version: self.version.clone(), + config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null), + disabled_reason: self.disabled_reason.clone(), + } + } + + // Get the `.codex/` folder associated with this config layer, if any. + pub fn config_folder(&self) -> Option { + match &self.name { + ConfigLayerSource::PackagedDefaults { .. } => None, + ConfigLayerSource::Mdm { .. } => None, + ConfigLayerSource::System { file } => file.parent(), + ConfigLayerSource::EnterpriseManaged { .. } => None, + ConfigLayerSource::User { file, .. } => file.parent(), + ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), + ConfigLayerSource::SessionFlags => None, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None, + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, + } + } + + /// Returns the `.codex/` folder that should be used for hook declarations. + /// + /// Project layers normally use their own config folder. Linked Git worktrees + /// can instead point hook discovery at the matching folder from the root + /// checkout while the rest of the project config still comes from the + /// worktree. + pub fn hooks_config_folder(&self) -> Option { + self.hooks_config_folder_override + .clone() + .or_else(|| self.config_folder()) + } +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ConfigLayerStack { + /// Cached TOML projection derived only from `requirements_toml`. + /// Construction validates provider definitions and reports serialization errors, + /// so `effective_config()` can replace complete entries without a fallible conversion. + model_provider_requirements: Option, + /// Layers are listed from lowest precedence (base) to highest (top), so + /// later entries in the Vec override earlier ones. + layers: Vec, + + /// Constraints that must be enforced when deriving a [Config] from the + /// layers. + requirements: ConfigRequirements, + + /// Raw requirements data as loaded from requirements.toml/MDM/legacy + /// sources. This preserves the original allow-lists so they can be + /// surfaced via APIs. + requirements_toml: ConfigRequirementsToml, + + /// Whether execpolicy should skip `.rules` files from user and project config-layer folders. + ignore_user_and_project_exec_policy_rules: bool, + + /// Startup warnings discovered while building this stack. + /// + /// `None` means the loader did not check for stack-level warnings, while + /// `Some(vec![])` means it checked and found nothing to report. + startup_warnings: Option>, +} + +impl ConfigLayerStack { + pub fn new( + layers: Vec, + requirements: ConfigRequirements, + requirements_toml: ConfigRequirementsToml, + ) -> std::io::Result { + validate_enabled_config_layers(&layers)?; + verify_layer_ordering(&layers)?; + let model_provider_requirements = Some(crate::model_provider_requirements::to_config( + &requirements_toml, + )?); + Ok(Self { + model_provider_requirements, + layers, + requirements, + requirements_toml, + ignore_user_and_project_exec_policy_rules: false, + startup_warnings: None, + }) + } + + pub fn with_user_and_project_exec_policy_rules_ignored( + mut self, + ignore_user_and_project_exec_policy_rules: bool, + ) -> Self { + self.ignore_user_and_project_exec_policy_rules = ignore_user_and_project_exec_policy_rules; + self + } + + pub fn ignore_user_and_project_exec_policy_rules(&self) -> bool { + self.ignore_user_and_project_exec_policy_rules + } + + pub(crate) fn with_startup_warnings(mut self, startup_warnings: Vec) -> Self { + self.startup_warnings = Some(startup_warnings); + self + } + + pub fn startup_warnings(&self) -> Option<&[String]> { + self.startup_warnings.as_deref() + } + + /// Returns the active raw user config layer, if any. + /// + /// This does not merge other config layers or apply any requirements. When + /// a profile-v2 layer is active, this returns that profile layer rather than + /// the base `$CODEX_HOME/config.toml` layer because the active layer is the + /// writable target for profile-aware edits. + pub fn get_active_user_layer(&self) -> Option<&ConfigLayerEntry> { + self.layers + .iter() + .rev() + .find(|layer| matches!(layer.name, ConfigLayerSource::User { .. })) + } + + pub fn get_user_config_file(&self) -> Option<&AbsolutePathBuf> { + let layer = self.get_active_user_layer()?; + let ConfigLayerSource::User { file, .. } = &layer.name else { + return None; + }; + Some(file) + } + + /// Returns the merged config from enabled user layers only. + /// + /// When profile config is active, this includes the base user config followed + /// by the profile override config. + pub fn effective_user_config(&self) -> Option { + let mut user_layers = self + .layers_low_to_high() + .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. })) + .peekable(); + user_layers.peek()?; + + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in user_layers { + merge_toml_values(&mut merged, &layer.config); + } + Some(merged) + } + + pub fn requirements(&self) -> &ConfigRequirements { + &self.requirements + } + + pub fn requirements_toml(&self) -> &ConfigRequirementsToml { + &self.requirements_toml + } + + /// Creates a new [ConfigLayerStack] using the specified values to inject one + /// user layer into the stack. If such a layer already exists, it is replaced; + /// otherwise, it is inserted into the stack at the appropriate position + /// based on precedence rules. When the stack has both base and profile-v2 + /// user layers, this updates only the layer whose file matches + /// `config_toml`. + pub fn with_user_config( + &self, + config_toml: &AbsolutePathBuf, + user_config: TomlValue, + ) -> std::io::Result { + let profile = self.layers.iter().find_map(|layer| match &layer.name { + ConfigLayerSource::User { file, profile } if file == config_toml => profile + .as_deref() + .and_then(|profile| profile.parse::().ok()), + _ => None, + }); + self.with_user_config_profile(config_toml, profile.as_ref(), user_config) + } + + pub fn with_user_config_profile( + &self, + config_toml: &AbsolutePathBuf, + profile: Option<&ProfileV2Name>, + user_config: TomlValue, + ) -> std::io::Result { + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: config_toml.clone(), + profile: profile.map(ToString::to_string), + }, + user_config, + ); + validate_enabled_config_layers(std::slice::from_ref(&user_layer))?; + + let mut layers = self.layers.clone(); + if let Some(index) = layers.iter().position(|layer| { + matches!( + &layer.name, + ConfigLayerSource::User { file, .. } if file == config_toml + ) + }) { + layers.remove(index); + } + match layers + .iter() + .position(|layer| layer.name.precedence() > user_layer.name.precedence()) + { + Some(index) => layers.insert(index, user_layer), + None => layers.push(user_layer), + } + Ok(Self { + layers, + model_provider_requirements: self.model_provider_requirements.clone(), + requirements: self.requirements.clone(), + requirements_toml: self.requirements_toml.clone(), + ignore_user_and_project_exec_policy_rules: self + .ignore_user_and_project_exec_policy_rules, + startup_warnings: self.startup_warnings.clone(), + }) + } + + /// Returns a new stack with the user layer copied from `other`, preserving + /// every non-user layer already present in this stack. + pub fn with_user_layer_from(&self, other: &Self) -> Self { + let user_layers = other + .layers + .iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. })) + .cloned() + .collect::>(); + let mut layers = self + .layers + .iter() + .filter(|layer| !matches!(layer.name, ConfigLayerSource::User { .. })) + .cloned() + .collect::>(); + for user_layer in user_layers { + match layers + .iter() + .position(|layer| layer.name.precedence() > user_layer.name.precedence()) + { + Some(index) => layers.insert(index, user_layer), + None => layers.push(user_layer), + } + } + Self { + layers, + model_provider_requirements: self.model_provider_requirements.clone(), + requirements: self.requirements.clone(), + requirements_toml: self.requirements_toml.clone(), + ignore_user_and_project_exec_policy_rules: self + .ignore_user_and_project_exec_policy_rules, + startup_warnings: self.startup_warnings.clone(), + } + } + + /// Returns the merged config-layer view. + /// + /// Required provider definitions replace local entries before deserialization. + /// Selection and other requirements are applied when constructing the final config. + pub fn effective_config(&self) -> TomlValue { + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in self.layers_low_to_high() { + merge_toml_values(&mut merged, &layer.config); + } + if let Some(requirements) = &self.model_provider_requirements { + crate::model_provider_requirements::apply(&mut merged, requirements); + } + merged + } + + /// Required provider selection used when building the effective configuration. + pub fn required_model_provider(&self) -> Option<&str> { + self.requirements_toml.model_provider.as_deref() + } + + /// Returns field origins for the merged config-layer view. + /// + /// Requirement sources are tracked separately and are not included here. + pub fn origins(&self) -> HashMap { + self.origins_with_path_filter(|_| true) + } + + /// Filters origins using their original TOML key segments before formatting + /// them for the public API, where dots in quoted keys are ambiguous. + pub fn origins_with_path_filter( + &self, + include: impl Fn(&[String]) -> bool, + ) -> HashMap { + let mut origins = HashMap::new(); + let mut path = Vec::new(); + let mut provider_paths = vec!["features.network_proxy.credentials.".to_string()]; + + for layer in self.layers_low_to_high() { + let config = normalized_with_key_aliases(&layer.config, &[]); + if let Some(profiles) = config.get("profiles").and_then(TomlValue::as_table) { + provider_paths.extend( + profiles + .keys() + .map(|name| format!("profiles.{name}.features.network_proxy.credentials.")), + ); + } + record_origins( + &config, + &layer.metadata(), + &mut path, + &mut origins, + &include, + ); + } + + if let Some(layer) = self.layers_low_to_high().next_back() { + let effective = self.effective_config(); + let mut effective_origins = HashMap::new(); + record_origins( + &effective, + &layer.metadata(), + &mut path, + &mut effective_origins, + &include, + ); + origins.retain(|path, _| { + !provider_paths.iter().any(|prefix| path.starts_with(prefix)) + || effective_origins.contains_key(path) + }); + } + + origins + } + + /// Returns enabled config layers from lowest precedence to highest. + /// + /// Requirement sources are tracked separately and are not included here. + pub fn layers_low_to_high(&self) -> impl DoubleEndedIterator { + self.all_layers_low_to_high() + .filter(|layer| !layer.is_disabled()) + } + + /// Returns enabled config layers from highest precedence to lowest. + /// + /// Requirement sources are tracked separately and are not included here. + pub fn layers_high_to_low(&self) -> impl DoubleEndedIterator { + self.layers_low_to_high().rev() + } + + /// Returns all config layers, including disabled layers, from lowest + /// precedence to highest. + /// + /// Requirement sources are tracked separately and are not included here. + pub fn all_layers_low_to_high(&self) -> impl DoubleEndedIterator { + self.layers.iter() + } + + /// Returns all config layers, including disabled layers, from highest + /// precedence to lowest. + /// + /// Requirement sources are tracked separately and are not included here. + pub fn all_layers_high_to_low(&self) -> impl DoubleEndedIterator { + self.all_layers_low_to_high().rev() + } +} + +/// Validates before merging so mixed forms and malformed filter entries cannot be normalized away. +pub(crate) fn validate_enabled_config_layers(layers: &[ConfigLayerEntry]) -> std::io::Result<()> { + for layer in layers.iter().filter(|layer| !layer.is_disabled()) { + validate_shell_environment_policy_filter_config(&layer.config).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "invalid shell environment policy in {}: {error}", + format_config_layer_source(&layer.name, CONFIG_TOML_FILE) + ), + ) + })?; + } + Ok(()) +} + +/// Ensures precedence ordering of config layers is correct. +fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<()> { + if !layers.iter().map(|layer| &layer.name).is_sorted() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "config layers are not in correct precedence order", + )); + } + + // The previous check ensured `layers` is sorted by precedence, so now we + // further verify that project layers are ordered from root to cwd. Multiple + // user layers are allowed so a profile override can layer on top of the base + // user config. + let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None; + for layer in layers { + if let ConfigLayerSource::Project { + dot_codex_folder: current_project_dot_codex_folder, + } = &layer.name + { + if let Some(previous) = previous_project_dot_codex_folder { + let Some(parent) = previous.as_path().parent() else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "project layer has no parent directory", + )); + }; + if previous == current_project_dot_codex_folder + || !current_project_dot_codex_folder + .as_path() + .ancestors() + .any(|ancestor| ancestor == parent) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "project layers are not ordered from root to cwd", + )); + } + } + previous_project_dot_codex_folder = Some(current_project_dot_codex_folder); + } + } + + Ok(()) +} + +#[cfg(test)] +#[path = "state_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/state_tests.rs b/codex-rs/config/src/state_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..8dad010f4a6b799de4a7cacae31f60c73c2a1e73 --- /dev/null +++ b/codex-rs/config/src/state_tests.rs @@ -0,0 +1,398 @@ +use super::*; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn test_user_config_path(temp_dir: &TempDir, file_name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name)) + .expect("test user config path should be absolute") +} + +#[test] +fn origins_use_canonical_key_aliases() { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str( + r#" +[memories] +no_memories_if_mcp_or_web_search = true +"#, + ) + .expect("config TOML should parse"), + ); + let metadata = layer.metadata(); + let stack = ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("single layer stack should be valid"); + + let origins = stack.origins(); + + assert_eq!( + origins.get("memories.disable_on_external_context"), + Some(&metadata) + ); + assert!( + !origins.contains_key("memories.no_memories_if_mcp_or_web_search"), + "legacy key should be canonicalized before origin recording" + ); +} + +/// Legacy feature toggles own the semantic enabled leaf after layered merging. +#[test] +fn origins_attribute_multi_agent_v2_enabled_to_overriding_boolean_layer() { + let temp_dir = TempDir::new().expect("tempdir"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_user_config_path(&temp_dir, "config.toml"), + profile: None, + }, + toml::from_str( + "[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"keep\"\n", + ) + .expect("user config"), + ); + let user_metadata = user_layer.metadata(); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str("[features]\nmulti_agent_v2 = false\n").expect("session config"), + ); + let session_metadata = session_layer.metadata(); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let origins = stack.origins(); + + assert_eq!( + origins.get("features.multi_agent_v2.enabled"), + Some(&session_metadata) + ); + assert_eq!( + origins.get("features.multi_agent_v2.subagent_usage_hint_text"), + Some(&user_metadata) + ); +} + +#[test] +fn origins_omit_displaced_credential_providers() { + let temp_dir = TempDir::new().expect("tempdir"); + for scope in ["", "profiles.work."] { + let user = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_user_config_path(&temp_dir, "config.toml"), + profile: None, + }, + toml::from_str(&format!( + "[{scope}features.network_proxy.credentials.user_provider]\nenv = ['VENDOR_TOKEN']\n", + )) + .expect("user config"), + ); + let session = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(&format!( + "[{scope}features.network_proxy.credentials.session_provider]\nenv = ['VENDOR_TOKEN']\n", + )) + .expect("session config"), + ); + let metadata = session.metadata(); + let stack = ConfigLayerStack::new( + vec![user, session], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid layers"); + assert_eq!( + stack.origins(), + HashMap::from([( + format!("{scope}features.network_proxy.credentials.session_provider.env.0"), + metadata + )]) + ); + } +} + +#[test] +fn enabled_layers_validate_shell_environment_policy() { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str( + r#" +[shell_environment_policy] +exclude = ["LEGACY_*"] + +[shell_environment_policy.filters] +"CANONICAL_*" = "include" +"#, + ) + .expect("session config"), + ); + + let error = ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect_err("enabled layers should be validated"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!( + error + .to_string() + .contains("cannot mix `filters` with legacy `exclude` or `include_only`") + ); +} + +#[test] +fn disabled_layers_do_not_validate_shell_environment_policy() { + let layer = ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::from_absolute_path("/untrusted/.codex") + .expect("project path should be absolute"), + }, + toml::from_str( + r#" +[shell_environment_policy] +exclude = ["LEGACY_*"] + +[shell_environment_policy.filters] +"CANONICAL_*" = "include" +"#, + ) + .expect("project config"), + "project is untrusted", + ); + + ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("disabled layers should not be validated"); +} + +#[test] +fn enabled_layers_only_validate_representation_sensitive_shell_policy_fields() { + let cases = [ + r#"shell_environment_policy = 17"#, + r#" +[shell_environment_policy] +inherit = "invalid" +set = ["invalid"] +"#, + ]; + + for contents in cases { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(contents).expect("session config"), + ); + + ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("unrelated shell policy fields should retain normal overlay semantics"); + } +} + +#[test] +fn with_user_config_rejects_malformed_shell_policy_filter_fields() { + let temp_dir = TempDir::new().expect("tempdir"); + let config_file = test_user_config_path(&temp_dir, "config.toml"); + let cases = [ + r#" +[shell_environment_policy] +exclude = ["SECRET_*", 17] +"#, + r#" +[shell_environment_policy.filters] +"SECRET_*" = "keep" +"#, + r#" +[shell_environment_policy] +exclude = ["SECRET_*"] + +[shell_environment_policy.filters] +"PATH" = "include" +"#, + r#" +[shell_environment_policy.filters] +"SECRET_*" = "exclude" +"secret_*" = "include" +"#, + ]; + + for contents in cases { + let error = ConfigLayerStack::default() + .with_user_config(&config_file, toml::from_str(contents).expect("user config")) + .expect_err("malformed shell policy filter fields should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } +} + +#[test] +fn active_user_layer_is_highest_precedence_user_layer() { + let temp_dir = TempDir::new().expect("tempdir"); + let base_file = test_user_config_path(&temp_dir, "config.toml"); + let profile_file = test_user_config_path(&temp_dir, "work.config.toml"); + let base_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: base_file, + profile: None, + }, + toml::from_str( + r#" +model = "base" +approval_policy = "on-request" +"#, + ) + .expect("base config"), + ); + let profile_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: profile_file.clone(), + profile: Some("work".to_string()), + }, + toml::from_str(r#"model = "profile""#).expect("profile config"), + ); + let stack = ConfigLayerStack::new( + vec![base_layer, profile_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("multiple user layers should be valid"); + + assert_eq!(stack.get_user_config_file(), Some(&profile_file)); + assert_eq!( + stack + .effective_user_config() + .expect("merged user config") + .get("model") + .and_then(toml::Value::as_str), + Some("profile") + ); + assert_eq!( + stack + .effective_user_config() + .expect("merged user config") + .get("approval_policy") + .and_then(toml::Value::as_str), + Some("on-request") + ); +} + +#[test] +fn layer_iterators_preserve_precedence_and_disabled_layers() { + let temp_dir = TempDir::new().expect("tempdir"); + let user_source = ConfigLayerSource::User { + file: test_user_config_path(&temp_dir, "config.toml"), + profile: None, + }; + let project_source = ConfigLayerSource::Project { + dot_codex_folder: test_user_config_path(&temp_dir, ".codex"), + }; + let session_source = ConfigLayerSource::SessionFlags; + let empty_config = TomlValue::Table(toml::map::Map::new()); + let stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new(user_source.clone(), empty_config.clone()), + ConfigLayerEntry::new_disabled( + project_source.clone(), + empty_config.clone(), + "project is untrusted", + ), + ConfigLayerEntry::new(session_source.clone(), empty_config), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + assert_eq!( + stack + .layers_low_to_high() + .map(|layer| &layer.name) + .collect::>(), + vec![&user_source, &session_source] + ); + assert_eq!( + stack + .layers_high_to_low() + .map(|layer| &layer.name) + .collect::>(), + vec![&session_source, &user_source] + ); + assert_eq!( + stack + .all_layers_low_to_high() + .map(|layer| &layer.name) + .collect::>(), + vec![&user_source, &project_source, &session_source] + ); + assert_eq!( + stack + .all_layers_high_to_low() + .map(|layer| &layer.name) + .collect::>(), + vec![&session_source, &project_source, &user_source] + ); +} + +#[test] +fn with_user_config_updates_matching_user_layer_without_replacing_active_profile() { + let temp_dir = TempDir::new().expect("tempdir"); + let base_file = test_user_config_path(&temp_dir, "config.toml"); + let profile_file = test_user_config_path(&temp_dir, "work.config.toml"); + let base_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: base_file.clone(), + profile: None, + }, + toml::from_str(r#"model = "base""#).expect("base config"), + ); + let profile_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: profile_file.clone(), + profile: Some("work".to_string()), + }, + toml::from_str(r#"approval_policy = "on-request""#).expect("profile config"), + ); + let stack = ConfigLayerStack::new( + vec![base_layer, profile_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("multiple user layers should be valid"); + + let updated = stack + .with_user_config( + &base_file, + toml::from_str(r#"model = "updated-base""#).expect("updated base config"), + ) + .expect("updated user layer should be valid"); + + assert_eq!(updated.get_user_config_file(), Some(&profile_file)); + assert_eq!( + updated + .effective_user_config() + .expect("merged user config") + .get("model") + .and_then(toml::Value::as_str), + Some("updated-base") + ); + assert_eq!( + updated + .effective_user_config() + .expect("merged user config") + .get("approval_policy") + .and_then(toml::Value::as_str), + Some("on-request") + ); +} diff --git a/codex-rs/config/src/strict_config_tests.rs b/codex-rs/config/src/strict_config_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0463848db2f8b260dc523943557748bffd929d14 --- /dev/null +++ b/codex-rs/config/src/strict_config_tests.rs @@ -0,0 +1,203 @@ +use super::*; +use crate::ConfigLayerEntry; +use crate::ConfigLayerSource; +use crate::ConfigRequirements; +use crate::config_toml::ConfigToml; +use crate::diagnostics::TextPosition; +use crate::diagnostics::TextRange; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +#[test] +fn ignored_toml_field_errors_accept_non_file_source_names() { + let source_name = "com.openai.codex:config_toml_base64"; + let contents = r#" +model = "gpt-5" +unknown_key = true"#; + + let value = toml::from_str::(contents).expect("valid TOML"); + let error = config_error_from_ignored_toml_value_fields_for_source_name::( + source_name, + contents, + value, + ) + .expect("unknown field error"); + + assert_eq!( + error, + ConfigError::new( + PathBuf::from(source_name), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { + line: 3, + column: 11, + }, + }, + "unknown configuration field `unknown_key`", + ) + ); +} + +#[test] +fn type_errors_take_precedence_over_ignored_fields() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +model_context_window = "wide" +unknown_key = true"#; + + let error = + config_error_from_ignored_toml_fields::(path, contents).expect("type error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { + line: 2, + column: 24, + }, + end: TextPosition { + line: 2, + column: 29, + }, + }, + "invalid type: string \"wide\", expected i64", + ) + ); +} + +#[test] +fn strict_config_rejects_unknown_feature_key() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[features] +foo = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents) + .expect("unknown feature error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { line: 3, column: 3 }, + }, + "unknown configuration field `features.foo`", + ) + ); +} + +#[test] +fn strict_config_accepts_tool_registry_config() { + let path = Path::new("/tmp/config.toml"); + + for contents in [ + "[features.tool_registry]\nerror_on_tool_collisions = true\n", + "[profiles.work.features.tool_registry]\nerror_on_tool_collisions = true\n", + "[features.tool_registry]\nturn_metadata_includes_tool_info = true\n", + "[profiles.work.features.tool_registry]\nturn_metadata_includes_tool_info = true\n", + ] { + assert_eq!( + config_error_from_ignored_toml_fields::(path, contents), + None + ); + } + + assert!( + config_error_from_ignored_toml_fields::( + path, + "[features.tool_registry]\nunknown = true\n", + ) + .is_some() + ); +} + +#[test] +fn strict_config_rejects_unknown_profile_feature_key() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[profiles.work.features] +foo = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents) + .expect("unknown feature error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { line: 3, column: 3 }, + }, + "unknown configuration field `profiles.work.features.foo`", + ) + ); +} + +#[test] +fn strict_config_accepts_opaque_desktop_keys() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[desktop] +appearanceTheme = "dark" + +[desktop.workspace] +collapsed = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents); + + assert_eq!(error, None); +} + +fn layer(source: ConfigLayerSource, contents: &str) -> ConfigLayerEntry { + ConfigLayerEntry::new(source, toml::from_str(contents).unwrap()) +} + +#[test] +fn checks_merged_config_and_ignores_disabled_layers() { + let layers = vec![ + layer( + ConfigLayerSource::EnterpriseManaged { + id: "cfg".into(), + name: "Defaults".into(), + }, + r#" +[model_providers.custom] +name = "Custom" +wire_api = "responses" +unknown_timeout = 1 +[model_providers.custom.http_headers] +custom_header = "accepted" +"#, + ), + ConfigLayerEntry::new_disabled( + ConfigLayerSource::SessionFlags, + toml::from_str("disabled_unknown = true").unwrap(), + "not trusted", + ), + layer( + ConfigLayerSource::SessionFlags, + r#" +[model_providers.custom] +unknown_timeout = 42 +[profiles.work.features] +include_view_image_tool = false +"#, + ), + ]; + let config = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .unwrap(); + assert_eq!( + ignored_config_warning(&config, &[]).unwrap(), + "Codex is ignoring 3 unrecognized configuration settings. Check for typos or deprecated settings.\n enterprise-managed (Defaults, cfg): `model_providers.custom.unknown_timeout` is ignored.\n session-flags: `model_providers.custom.unknown_timeout` is ignored.\n session-flags: `profiles.work.features.include_view_image_tool` is ignored. Use [features].view_image to configure the image tool." + ); +} diff --git a/codex-rs/config/src/test_support.rs b/codex-rs/config/src/test_support.rs new file mode 100644 index 0000000000000000000000000000000000000000..44627f38924878c1c6d3537f0302b1e5660a8d3e --- /dev/null +++ b/codex-rs/config/src/test_support.rs @@ -0,0 +1,80 @@ +//! Test-only helpers exposed for cross-crate integration tests. +//! +//! Production code should not depend on this module. + +use crate::CloudConfigBundle; +use crate::CloudConfigBundleLoader; +use crate::CloudConfigFragment; +use crate::CloudRequirementsFragment; + +#[derive(Debug, Clone, Default)] +pub struct CloudConfigBundleFixture { + bundle: CloudConfigBundle, +} + +impl CloudConfigBundleFixture { + pub fn enterprise_requirement(contents: impl Into) -> Self { + Self::default().add_enterprise_requirement(contents) + } + + pub fn enterprise_config(contents: impl Into) -> Self { + Self::default().add_enterprise_config(contents) + } + + pub fn loader_with_enterprise_requirement( + contents: impl Into, + ) -> CloudConfigBundleLoader { + Self::enterprise_requirement(contents).into_loader() + } + + pub fn loader_with_enterprise_config(contents: impl Into) -> CloudConfigBundleLoader { + Self::enterprise_config(contents).into_loader() + } + + pub fn add_enterprise_requirement(mut self, contents: impl Into) -> Self { + let index = self.bundle.requirements_toml.enterprise_managed.len() + 1; + self.bundle + .requirements_toml + .enterprise_managed + .push(CloudRequirementsFragment { + id: format!("req_{index}"), + name: if index == 1 { + "Base requirements".to_string() + } else { + format!("Requirements {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn add_enterprise_config(mut self, contents: impl Into) -> Self { + let index = self.bundle.config_toml.enterprise_managed.len() + 1; + self.bundle + .config_toml + .enterprise_managed + .push(CloudConfigFragment { + id: format!("cfg_{index}"), + name: if index == 1 { + "Base config".to_string() + } else { + format!("Config {index}") + }, + contents: contents.into(), + }); + self + } + + pub fn into_bundle(self) -> CloudConfigBundle { + self.bundle + } + + pub fn into_loader(self) -> CloudConfigBundleLoader { + let bundle = self.into_bundle(); + CloudConfigBundleLoader::new(async move { Ok(Some(bundle)) }) + } +} + +#[cfg(test)] +#[path = "test_support_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/thread_config.rs b/codex-rs/config/src/thread_config.rs new file mode 100644 index 0000000000000000000000000000000000000000..76d76fd59f0ecc575d5ad9679d09d0a7b36fba64 --- /dev/null +++ b/codex-rs/config/src/thread_config.rs @@ -0,0 +1,319 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; + +use crate::ConfigLayerSource; +use codex_model_provider_info::ModelProviderInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use thiserror::Error; +use toml::Value as TomlValue; + +use crate::ConfigLayerEntry; + +mod remote; + +pub use remote::RemoteThreadConfigLoader; + +/// Context available to implementations when loading thread-scoped config. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ThreadConfigContext { + pub thread_id: Option, + pub cwd: Option, +} + +/// Config values owned by the service that starts or manages the session. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SessionThreadConfig { + pub model_provider: Option, + pub model_providers: HashMap, + pub features: BTreeMap, +} + +/// Config values owned by the authenticated user. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UserThreadConfig {} + +/// A typed config payload paired with the authority that produced it. +#[derive(Clone, Debug, PartialEq)] +pub enum ThreadConfigSource { + Session(SessionThreadConfig), + User(UserThreadConfig), +} + +/// Stable category for failures returned while loading thread config. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ThreadConfigLoadErrorCode { + Auth, + Timeout, + Parse, + RequestFailed, + Internal, +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("{message}")] +pub struct ThreadConfigLoadError { + code: ThreadConfigLoadErrorCode, + message: String, + status_code: Option, +} + +impl ThreadConfigLoadError { + pub fn new( + code: ThreadConfigLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { + Self { + code, + message: message.into(), + status_code, + } + } + + pub fn code(&self) -> ThreadConfigLoadErrorCode { + self.code + } +} + +/// Loads typed config sources for a new thread. +/// +/// Implementations should fetch only the source-specific config they own and +/// return typed payloads without applying precedence or merge rules. Callers +/// are responsible for resolving the returned sources into the effective +/// runtime config. +pub trait ThreadConfigLoader: Send + Sync { + /// Load source-specific typed config. + /// + /// Implementations should keep this method focused on fetching and parsing + /// their owned sources. Most callers should use [`Self::load_config_layers`] + /// so precedence and merging continue through the ordinary config layer + /// stack. + fn load( + &self, + context: ThreadConfigContext, + ) -> ThreadConfigLoaderFuture<'_, Vec>; + + fn load_config_layers( + &self, + context: ThreadConfigContext, + ) -> ThreadConfigLoaderFuture<'_, Vec> { + Box::pin(async move { + let sources = self.load(context).await?; + sources + .into_iter() + .map(thread_config_source_to_layer) + .collect::, _>>() + .map(|layers| layers.into_iter().flatten().collect()) + }) + } +} + +pub type ThreadConfigLoaderFuture<'a, T> = + Pin> + Send + 'a>>; + +/// Loader backed by a static set of typed thread config sources. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StaticThreadConfigLoader { + sources: Vec, +} + +impl StaticThreadConfigLoader { + pub fn new(sources: Vec) -> Self { + Self { sources } + } +} + +impl ThreadConfigLoader for StaticThreadConfigLoader { + fn load( + &self, + _context: ThreadConfigContext, + ) -> ThreadConfigLoaderFuture<'_, Vec> { + Box::pin(async { Ok(self.sources.clone()) }) + } +} + +/// Loader used when no external thread config source is configured. +#[derive(Clone, Debug, Default)] +pub struct NoopThreadConfigLoader; + +impl ThreadConfigLoader for NoopThreadConfigLoader { + fn load( + &self, + _context: ThreadConfigContext, + ) -> ThreadConfigLoaderFuture<'_, Vec> { + Box::pin(async { Ok(Vec::new()) }) + } +} + +fn thread_config_source_to_layer( + source: ThreadConfigSource, +) -> Result, ThreadConfigLoadError> { + match source { + ThreadConfigSource::Session(config) => { + let config = session_thread_config_to_toml(config)?; + if is_empty_table(&config) { + Ok(None) + } else { + Ok(Some(ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + config, + ))) + } + } + // UserThreadConfig has no TOML-backed fields yet. When it grows one, + // fold it into the existing user layer instead of adding another + // ConfigLayerSource variant. + ThreadConfigSource::User(_config) => Ok(None), + } +} + +fn is_empty_table(config: &TomlValue) -> bool { + config.as_table().is_some_and(toml::map::Map::is_empty) +} + +fn session_thread_config_to_toml( + config: SessionThreadConfig, +) -> Result { + let mut table = toml::map::Map::new(); + + if let Some(model_provider) = config.model_provider { + table.insert( + "model_provider".to_string(), + TomlValue::String(model_provider), + ); + } + + if !config.model_providers.is_empty() { + let model_providers = TomlValue::try_from(config.model_providers).map_err(|err| { + ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Parse, + /*status_code*/ None, + format!("failed to convert session model providers to config TOML: {err}"), + ) + })?; + table.insert("model_providers".to_string(), model_providers); + } + + if !config.features.is_empty() { + let features = config + .features + .into_iter() + .map(|(feature, enabled)| (feature, TomlValue::Boolean(enabled))) + .collect(); + table.insert("features".to_string(), TomlValue::Table(features)); + } + + Ok(TomlValue::Table(table)) +} + +#[cfg(test)] +mod tests { + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; + use pretty_assertions::assert_eq; + + use super::*; + + #[tokio::test] + async fn loader_returns_session_and_user_sources() { + let loader = StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ThreadConfigSource::User(UserThreadConfig::default()), + ]); + + let sources = loader + .load(ThreadConfigContext { + thread_id: Some("thread-1".to_string()), + ..Default::default() + }) + .await + .expect("thread config loads"); + + assert_eq!( + sources, + vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ThreadConfigSource::User(UserThreadConfig::default()), + ] + ); + } + + #[tokio::test] + async fn loader_translates_sources_to_config_layers() { + let loader = StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::User(UserThreadConfig::default()), + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("local".to_string()), + model_providers: HashMap::from([("local".to_string(), test_provider("local"))]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ]); + let layers = loader + .load_config_layers(ThreadConfigContext { + cwd: Some( + AbsolutePathBuf::from_absolute_path_checked( + std::env::temp_dir().join("project"), + ) + .expect("absolute cwd"), + ), + ..Default::default() + }) + .await + .expect("thread config layers load"); + + assert_eq!( + layers, + vec![ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::toml! { + model_provider = "local" + + [model_providers.local] + name = "local" + base_url = "http://127.0.0.1:8061/api/codex" + wire_api = "responses" + requires_openai_auth = false + supports_websockets = true + supports_standalone_web_search = true + + [features] + plugins = false + } + .into() + )] + ); + } + + fn test_provider(name: &str) -> ModelProviderInfo { + ModelProviderInfo { + name: name.to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: None, + aws: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: true, + supports_standalone_web_search: true, + } + } +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs new file mode 100644 index 0000000000000000000000000000000000000000..709c95399094ba9d06caa03d8cd11175c0637186 --- /dev/null +++ b/codex-rs/config/src/types.rs @@ -0,0 +1,1057 @@ +//! Types used to define loaded and effective Codex configuration values. + +// Note this file should generally be restricted to simple struct/enum +// definitions that do not contain business logic. + +pub use crate::mcp_ema::McpEnterpriseManagedAuthConfig; +pub use crate::mcp_ema::McpServerIdpOAuthConfig; +pub use crate::mcp_types::AppToolApproval; +pub use crate::mcp_types::McpServerAuth; +pub use crate::mcp_types::McpServerConfig; +pub use crate::mcp_types::McpServerDisabledReason; +pub use crate::mcp_types::McpServerEnvVar; +pub use crate::mcp_types::McpServerOAuthConfig; +pub use crate::mcp_types::McpServerToolConfig; +pub use crate::mcp_types::McpServerTransportConfig; +pub use crate::mcp_types::RawMcpServerConfig; +pub use crate::shell_environment_policy::ShellEnvironmentPolicyToml; +pub use codex_protocol::config_types::AltScreenMode; +pub use codex_protocol::config_types::ApprovalsReviewer; +pub use codex_protocol::config_types::ModeKind; +pub use codex_protocol::config_types::Personality; +pub use codex_protocol::config_types::ServiceTier; +pub use codex_protocol::config_types::WebSearchMode; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::fmt; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +pub use crate::tui_keymap::KeybindingSpec; +pub use crate::tui_keymap::KeybindingsSpec; +pub use crate::tui_keymap::MAX_FUNCTION_KEY; +pub use crate::tui_keymap::TuiAgentsKeymap; +pub use crate::tui_keymap::TuiApprovalKeymap; +pub use crate::tui_keymap::TuiChatKeymap; +pub use crate::tui_keymap::TuiComposerKeymap; +pub use crate::tui_keymap::TuiEditorKeymap; +pub use crate::tui_keymap::TuiGlobalKeymap; +pub use crate::tui_keymap::TuiKeymap; +pub use crate::tui_keymap::TuiListKeymap; +pub use crate::tui_keymap::TuiPagerKeymap; +pub use crate::tui_keymap::TuiVimNormalKeymap; +pub use crate::tui_keymap::TuiVimOperatorKeymap; +pub use crate::tui_keymap::TuiVimSearchKeymap; + +pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev"; +pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2; +pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 10; +pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6; +pub const DEFAULT_MEMORIES_MIN_RATE_LIMIT_REMAINING_PERCENT: i64 = 25; +pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256; +pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30; +const MIN_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 1; +const MAX_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 4096; +const MIN_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 1; +const MAX_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 128; + +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()) + } +} + +/// Working directory to use when resuming or forking a session. +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ResumeCwdMode { + /// Use the directory where Codex was launched. + Current, + /// Use the latest working directory recorded in the selected session. + Session, +} + +impl ResumeCwdMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Current => "current", + Self::Session => "session", + } + } +} + +/// Determine where Codex should store CLI auth credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AuthCredentialsStoreMode { + #[default] + /// Persist credentials in CODEX_HOME/auth.json. + File, + /// Persist credentials in the keyring. Fail if unavailable. + Keyring, + /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. + Auto, + /// Store credentials in memory only for the current process. + Ephemeral, +} + +/// Determine where Codex should store and read MCP credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum OAuthCredentialsStoreMode { + /// Prefer `Keyring` and use `File` when keyring storage is unavailable. + /// Once an MCP client loads credentials from one store, that client keeps the resolved store + /// for its lifetime so refreshes cannot switch to a possibly stale credential source. + /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. + #[default] + Auto, + /// CODEX_HOME/.credentials.json + /// This file will be readable to Codex and other applications running as the same user. + File, + /// Keyring when available, otherwise fail. + Keyring, +} + +/// Determine how auth credentials should use keyring-backed storage. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum AuthKeyringBackendKind { + /// Store the serialized auth payload directly in the OS keyring. + Direct, + /// Store auth payloads in the local encrypted secrets file, with the file key in the OS keyring. + Secrets, +} + +impl Default for AuthKeyringBackendKind { + fn default() -> Self { + if cfg!(windows) { + Self::Secrets + } else { + Self::Direct + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum WindowsSandboxModeToml { + Elevated, + Unelevated, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct WindowsToml { + pub sandbox: Option, + /// Defaults to `true`. Set to `false` to launch the final sandboxed child + /// process on `Winsta0\\Default` instead of a private desktop. + pub sandbox_private_desktop: Option, +} + +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)] +pub enum UriBasedFileOpener { + #[serde(rename = "vscode")] + VsCode, + + #[serde(rename = "vscode-insiders")] + VsCodeInsiders, + + #[serde(rename = "windsurf")] + Windsurf, + + #[serde(rename = "cursor")] + Cursor, + + /// Option to disable the URI-based file opener. + #[serde(rename = "none")] + None, +} + +/// Settings that govern if and what will be written to `~/.codex/history.jsonl`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[serde(default)] +#[schemars(deny_unknown_fields)] +pub struct History { + /// If true, history entries will not be written to disk. + pub persistence: HistoryPersistence, + + /// If set, the maximum size of the history file in bytes. The oldest entries + /// are dropped once the file exceeds this limit. + pub max_bytes: Option, +} + +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum HistoryPersistence { + /// Save all history entries to disk. + #[default] + SaveAll, + /// Do not write history to disk. + None, +} + +// ===== Analytics configuration ===== + +/// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AnalyticsConfigToml { + /// When `false`, disables analytics across Codex product surfaces in this profile. + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct FeedbackConfigToml { + /// When `false`, disables the feedback flow across Codex product surfaces. + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToolSuggestDiscoverableType { + Connector, + Plugin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestDiscoverable { + #[serde(rename = "type")] + pub kind: ToolSuggestDiscoverableType, + pub id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestDisabledTool { + #[serde(rename = "type")] + pub kind: ToolSuggestDiscoverableType, + pub id: String, +} + +impl ToolSuggestDisabledTool { + pub fn plugin(id: impl Into) -> Self { + Self { + kind: ToolSuggestDiscoverableType::Plugin, + id: id.into(), + } + } + + pub fn connector(id: impl Into) -> Self { + Self { + kind: ToolSuggestDiscoverableType::Connector, + id: id.into(), + } + } + + pub fn normalized(&self) -> Option { + let id = self.id.trim(); + (!id.is_empty()).then(|| Self { + kind: self.kind, + id: id.to_string(), + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestConfig { + #[serde(default)] + pub discoverables: Vec, + #[serde(default)] + pub disabled_tools: Vec, +} + +pub use codex_protocol::MemoryVersion; + +/// Memories settings loaded from config.toml. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MemoriesToml { + /// Selects the memory pipeline; v1 remains the default. + pub version: Option, + /// Generate both versions while the selected version supplies context. + pub dual_write: Option, + /// When `true`, external context sources mark the thread `memory_mode` as `"polluted"`. + #[serde(alias = "no_memories_if_mcp_or_web_search")] + pub disable_on_external_context: Option, + /// When `false`, newly created threads are stored with `memory_mode = "disabled"` in the state DB. + pub generate_memories: Option, + /// When `false`, skip injecting memory usage instructions into developer prompts. + pub use_memories: Option, + /// When `true`, expose dedicated memory tools through the extension tool surface. + pub dedicated_tools: Option, + /// Maximum number of recent raw memories retained for global consolidation. + #[schemars(range(min = 1, max = 4096))] + pub max_raw_memories_for_consolidation: Option, + /// Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection. + pub max_unused_days: Option, + /// Maximum age of the threads used for memories. + pub max_rollout_age_days: Option, + /// Maximum number of rollout candidates processed per pass. + #[schemars(range(min = 1, max = 128))] + pub max_rollouts_per_startup: Option, + /// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended. + pub min_rollout_idle_hours: Option, + /// Minimum remaining percentage required in Codex rate-limit windows before memory startup runs. + #[schemars(range(min = 0, max = 100))] + pub min_rate_limit_remaining_percent: Option, + /// Model used for thread summarisation. + pub extract_model: Option, + /// Model used for memory consolidation. + pub consolidation_model: Option, +} + +/// Effective memories settings after defaults are applied. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MemoriesConfig { + pub version: MemoryVersion, + pub dual_write: bool, + pub disable_on_external_context: bool, + pub generate_memories: bool, + pub use_memories: bool, + pub dedicated_tools: bool, + pub max_raw_memories_for_consolidation: usize, + pub max_unused_days: i64, + pub max_rollout_age_days: i64, + pub max_rollouts_per_startup: usize, + pub min_rollout_idle_hours: i64, + pub min_rate_limit_remaining_percent: i64, + pub extract_model: Option, + pub consolidation_model: Option, +} + +impl Default for MemoriesConfig { + fn default() -> Self { + Self { + version: MemoryVersion::V1, + dual_write: false, + disable_on_external_context: false, + generate_memories: true, + use_memories: true, + dedicated_tools: false, + max_raw_memories_for_consolidation: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, + max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS, + max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS, + max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP, + min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS, + min_rate_limit_remaining_percent: DEFAULT_MEMORIES_MIN_RATE_LIMIT_REMAINING_PERCENT, + extract_model: None, + consolidation_model: None, + } + } +} + +impl From for MemoriesConfig { + fn from(toml: MemoriesToml) -> Self { + let defaults = Self::default(); + Self { + version: toml.version.unwrap_or(defaults.version), + dual_write: toml.dual_write.unwrap_or(defaults.dual_write), + disable_on_external_context: toml + .disable_on_external_context + .unwrap_or(defaults.disable_on_external_context), + generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories), + use_memories: toml.use_memories.unwrap_or(defaults.use_memories), + dedicated_tools: toml.dedicated_tools.unwrap_or(defaults.dedicated_tools), + max_raw_memories_for_consolidation: toml + .max_raw_memories_for_consolidation + .unwrap_or(defaults.max_raw_memories_for_consolidation) + .clamp( + MIN_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, + MAX_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, + ), + max_unused_days: toml + .max_unused_days + .unwrap_or(defaults.max_unused_days) + .clamp(0, 365), + max_rollout_age_days: toml + .max_rollout_age_days + .unwrap_or(defaults.max_rollout_age_days) + .clamp(0, 90), + max_rollouts_per_startup: toml + .max_rollouts_per_startup + .unwrap_or(defaults.max_rollouts_per_startup) + .clamp( + MIN_MEMORIES_MAX_ROLLOUTS_PER_STARTUP, + MAX_MEMORIES_MAX_ROLLOUTS_PER_STARTUP, + ), + min_rollout_idle_hours: toml + .min_rollout_idle_hours + .unwrap_or(defaults.min_rollout_idle_hours) + .clamp(1, 48), + min_rate_limit_remaining_percent: toml + .min_rate_limit_remaining_percent + .unwrap_or(defaults.min_rate_limit_remaining_percent) + .clamp(0, 100), + extract_model: toml.extract_model, + consolidation_model: toml.consolidation_model, + } + } +} + +/// Default settings that apply to all apps. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsDefaultConfig { + /// When `false`, apps are disabled unless overridden by per-app settings. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Reviewer for approval prompts unless overridden by per-app settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + + /// Whether tools with `destructive_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub destructive_enabled: bool, + + /// Whether tools with `open_world_hint = true` are allowed by default. + #[serde( + default = "default_enabled", + skip_serializing_if = "std::clone::Clone::clone" + )] + pub open_world_enabled: bool, + + /// Approval mode for tools unless overridden by per-app or per-tool settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, +} + +/// Per-tool settings for a single app tool. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolConfig { + /// Whether this tool is enabled. `Some(true)` explicitly allows this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + + /// Approval mode for this tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, +} + +/// Tool settings for a single app. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppToolsConfig { + /// Per-tool overrides keyed by tool name (for example `repos/list`). + #[serde(default, flatten)] + pub tools: HashMap, +} + +/// Approval settings for a connected account within an app. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppLinkConfig { + /// Reviewer for approval prompts from this account, overriding the app default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + + /// Approval mode for this account unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, +} + +/// Account settings for a single app. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppLinksConfig { + /// Per-account approval settings keyed by link ID. + #[serde(default, flatten)] + pub links: HashMap, +} + +/// Config values for a single app/connector. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppConfig { + /// When `false`, Codex does not surface this app. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Reviewer for approval prompts from this app, overriding the thread default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals_reviewer: Option, + + /// Whether tools with `destructive_hint = true` are allowed for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destructive_enabled: Option, + + /// Whether tools with `open_world_hint = true` are allowed for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_world_enabled: Option, + + /// Approval mode for tools in this app unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Whether tools are enabled by default for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_enabled: Option, + + /// Per-tool settings for this app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, + + /// Per-account approval settings keyed by link ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub links: Option, +} + +/// App/connector settings loaded from `config.toml`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AppsConfigToml { + /// Default settings for all apps. + #[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Per-app settings keyed by app ID (for example `[apps.google_drive]`). + #[serde(default, flatten)] + pub apps: HashMap, +} + +// ===== OTEL configuration ===== + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum OtelHttpProtocol { + /// Binary payload + Binary, + /// JSON payload + Json, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +pub struct OtelTlsConfig { + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, +} + +/// Which OTEL exporter to use. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +pub enum OtelExporterKind { + None, + Statsig, + OtlpHttp { + endpoint: String, + #[serde(default)] + headers: HashMap, + protocol: OtelHttpProtocol, + #[serde(default)] + tls: Option, + }, + OtlpGrpc { + endpoint: String, + #[serde(default)] + headers: HashMap, + #[serde(default)] + tls: Option, + }, +} + +/// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct OtelConfigToml { + /// Byte limit for tool-result log output; independent of model-visible output. + #[serde(default)] + pub tool_result: codex_protocol::config_types::ToolResultLogConfig, + /// Log user prompt in traces + pub log_user_prompt: Option, + + /// Mark traces with environment (dev, staging, prod, test). Defaults to dev. + pub environment: Option, + + /// Optional log exporter + pub exporter: Option, + + /// Optional trace exporter + pub trace_exporter: Option, + + /// Optional metrics exporter + pub metrics_exporter: Option, + + /// Attributes to add to every exported trace span. + pub span_attributes: Option>, + + /// Semicolon-separated `key:value` fields to upsert into W3C tracestate members. + pub tracestate: Option>>, +} + +/// Effective OTEL settings after defaults are applied. +#[derive(Debug, Clone, PartialEq)] +pub struct OtelConfig { + pub tool_result: codex_protocol::config_types::ToolResultLogConfig, + pub log_user_prompt: bool, + pub environment: String, + pub exporter: OtelExporterKind, + pub trace_exporter: OtelExporterKind, + pub metrics_exporter: OtelExporterKind, + pub span_attributes: BTreeMap, + pub tracestate: BTreeMap>, +} + +impl Default for OtelConfig { + fn default() -> Self { + OtelConfig { + tool_result: Default::default(), + log_user_prompt: false, + environment: DEFAULT_OTEL_ENVIRONMENT.to_owned(), + exporter: OtelExporterKind::None, + trace_exporter: OtelExporterKind::None, + metrics_exporter: OtelExporterKind::Statsig, + span_attributes: BTreeMap::new(), + tracestate: BTreeMap::new(), + } + } +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum Notifications { + Enabled(bool), + Custom(Vec), +} + +impl Default for Notifications { + fn default() -> Self { + Self::Enabled(true) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum NotificationMethod { + #[default] + Auto, + Osc9, + Bel, +} + +impl fmt::Display for NotificationMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationMethod::Auto => write!(f, "auto"), + NotificationMethod::Osc9 => write!(f, "osc9"), + NotificationMethod::Bel => write!(f, "bel"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum NotificationCondition { + /// Emit TUI notifications only while the terminal is unfocused. + #[default] + Unfocused, + /// Emit TUI notifications regardless of terminal focus. + Always, +} + +impl fmt::Display for NotificationCondition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NotificationCondition::Unfocused => write!(f, "unfocused"), + NotificationCondition::Always => write!(f, "always"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TuiPetAnchor { + /// Anchor the pet to the bottom of the current TUI composer viewport. + #[default] + Composer, + /// Anchor the pet to the physical bottom of the terminal screen. + ScreenBottom, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiNotificationSettings { + /// Enable desktop notifications from the TUI. + /// Defaults to `true`. + #[serde(default, rename = "notifications")] + pub notifications: Notifications, + + /// Notification method to use for terminal notifications. + /// Defaults to `auto`. + #[serde(default, rename = "notification_method")] + pub method: NotificationMethod, + + /// Controls whether TUI notifications are delivered only when the terminal is unfocused or + /// regardless of focus. Defaults to `unfocused`. + #[serde(default, rename = "notification_condition")] + pub condition: NotificationCondition, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ModelAvailabilityNuxConfig { + /// Number of times a startup availability NUX has been shown per model slug. + #[serde(default, flatten)] + pub shown_count: HashMap, +} + +/// Fallback resize-reflow row cap when Codex cannot identify a terminal-specific scrollback size. +pub const DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS: usize = 1_000; + +/// Collection of settings that are specific to the TUI. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct Tui { + #[serde(default, flatten)] + pub notification_settings: TuiNotificationSettings, + + /// Enable animations (welcome screen, shimmer effects, spinners). + /// Defaults to `true`. + #[serde(default = "default_true")] + pub animations: bool, + + /// Enable decorative effects such as Astra composer stars. Also requires animations. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub whimsy: bool, + + /// Show startup tooltips in the TUI welcome screen. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_tooltips: bool, + + /// Show an informational notice when the connected app server is an older stable release. + /// Defaults to `true`; this does not control compatibility errors or version status. + #[serde(default = "default_true")] + pub show_server_version_notice: bool, + + /// Generate automatic conversation recaps when the terminal is unfocused. + /// Defaults to `true`. Disabling this leaves `/recap` available on demand. + #[serde(default = "default_true")] + pub auto_recap: bool, + + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + /// Overrides the legacy top-level `disable_paste_burst` setting. Defaults to `false`. + pub disable_paste_burst: Option, + + /// Start the composer in Vim mode (`Normal`) by default. + /// Defaults to `false`. + #[serde(default)] + pub vim_mode_default: bool, + + /// Escape returns from async questions to the composer, preserving the answer draft. + #[serde(default = "default_true")] + pub question_esc_back: bool, + + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. + /// Defaults to `false`. + #[serde(default)] + pub raw_output_mode: bool, + + /// Controls whether the TUI uses the terminal's alternate screen buffer. + /// + /// - `auto` (default): Use alternate screen. + /// - `always`: Always use alternate screen. + /// - `never`: Never use alternate screen (inline mode only, preserves scrollback). + #[serde(default)] + pub alternate_screen: AltScreenMode, + + /// Ordered list of status line item identifiers. + /// + /// When set, the TUI renders the selected items as the status line. + /// When unset, the TUI defaults to: `model-with-reasoning`, `current-dir`, and `thread-name`. + #[serde(default)] + pub status_line: Option>, + + /// Color status line items with colors derived from the active syntax theme. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub status_line_use_colors: bool, + + /// Ordered list of terminal title item identifiers. + /// + /// When set, the TUI renders the selected items into the terminal window/tab title. + /// When unset, the TUI defaults to: `activity`, `thread-name`, and `project-name`. + /// The `activity` item spins while working and shows an action-required + /// message when blocked on the user. + #[serde(default)] + pub terminal_title: Option>, + + /// Syntax highlighting theme name (kebab-case). + /// + /// When set, overrides automatic light/dark theme detection. + /// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes. + #[serde(default)] + pub theme: Option, + + /// Pet id to preselect in the terminal pet picker. + /// + /// Custom pet ids resolve against CODEX_HOME/pets//pet.json. + #[serde(default)] + pub pet: Option, + + /// Where the terminal pet should anchor vertically. + /// + /// Defaults to `composer`, which follows the current TUI composer viewport. + #[serde(default)] + pub pet_anchor: TuiPetAnchor, + + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, + + /// Working directory to use when resuming or forking a session. + /// When unset, prompt if the current and session directories differ. + #[serde(default)] + pub resume_cwd: Option, + + /// Keybinding overrides for the TUI. + /// + /// This supports rebinding selected actions globally and by context. + /// Context bindings take precedence over `global` bindings. + #[serde(default)] + pub keymap: TuiKeymap, + + /// Startup tooltip availability NUX state persisted by the TUI. + #[serde(default)] + pub model_availability_nux: ModelAvailabilityNuxConfig, + + /// Trim terminal resize-reflow replay to the most recent rendered terminal rows when the + /// transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to + /// keep all rendered rows. + #[serde(default)] + #[schemars(range(min = 0))] + pub terminal_resize_reflow_max_rows: Option, +} + +const fn default_true() -> bool { + true +} + +/// Settings for notices we display to users via the tui and app-server clients +/// (primarily the Codex IDE extension). NOTE: these are different from +/// notifications - notices are warnings, NUX screens, acknowledgements, etc. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ExternalConfigMigrationPrompts { + /// Tracks whether home-level external config migration prompts are hidden. + pub home: Option, + /// Tracks the last time the home-level external config migration prompt was shown. + pub home_last_prompted_at: Option, + /// Tracks which project paths have opted out of external config migration prompts. + #[serde(default)] + pub projects: BTreeMap, + /// Tracks the last time a project-level external config migration prompt was shown. + #[serde(default)] + pub project_last_prompted_at: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct Notice { + /// Tracks whether the user has acknowledged the full access warning prompt. + pub hide_full_access_warning: Option, + /// Tracks whether the user has acknowledged the Windows world-writable directories warning. + pub hide_world_writable_warning: Option, + /// Tracks whether the user opted out of Codex-managed fast defaults. + pub fast_default_opt_out: Option, + /// Tracks whether the user opted out of the rate limit model switch reminder. + pub hide_rate_limit_model_nudge: Option, + /// Tracks whether the user has seen the model migration prompt + pub hide_gpt5_1_migration_prompt: Option, + /// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt + #[serde(rename = "hide_gpt-5.1-codex-max_migration_prompt")] + pub hide_gpt_5_1_codex_max_migration_prompt: Option, + /// Tracks acknowledged model migrations as old->new model slug mappings. + #[serde(default)] + pub model_migrations: BTreeMap, + /// Tracks scopes where external config migration prompts should be suppressed. + #[serde(default)] + pub external_config_migration_prompts: ExternalConfigMigrationPrompts, +} + +pub use crate::skills_config::BundledSkillsConfig; +pub use crate::skills_config::SkillConfig; +pub use crate::skills_config::SkillsConfig; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PluginConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Per-MCP-server policy overlays for MCP servers contributed by this plugin. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub mcp_servers: HashMap, +} + +/// Policy settings for a plugin-provided MCP server. +/// +/// This intentionally excludes transport settings: plugin manifests own how the +/// MCP server is launched, while host config owns enablement, auth, and tool policy. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PluginMcpServerConfig { + /// When `false`, Codex skips initializing this plugin MCP server. + #[serde(default = "default_enabled")] + pub enabled: bool, + + /// Host-configured EMA registration; the plugin still owns its endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ema_auth: Option, + + /// Approval mode for tools in this server unless a tool override exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_tools_approval_mode: Option, + + /// Explicit allow-list of tools exposed from this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled_tools: Option>, + + /// Explicit deny-list of tools. These tools are removed after applying `enabled_tools`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disabled_tools: Option>, + + /// Per-tool policy settings keyed by tool name. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub tools: HashMap, +} + +impl Default for PluginMcpServerConfig { + fn default() -> Self { + Self { + enabled: true, + ema_auth: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + tools: HashMap::new(), + } + } +} + +/// Resource registration applied through an existing per-plugin policy overlay. +/// The enterprise IdP is selected separately by trusted host configuration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PluginMcpServerEmaAuthConfig { + /// Exact plugin endpoint approved by the host; never overrides the declaration. + pub url: String, + pub client_id: String, + pub authorization_server_issuer: String, + #[serde(default)] + pub scopes: Vec, + pub resource: String, +} + +impl PluginMcpServerEmaAuthConfig { + pub fn apply(&self, server: &mut McpServerConfig) { + let registration_error = if self.resource.trim().is_empty() { + Some("plugin EMA registration requires a resource") + } else if !server.matches_requirement(&crate::McpServerRequirement::Identity { + identity: crate::McpServerIdentity::Url { + url: self.url.clone(), + }, + }) { + Some("plugin endpoint does not match its EMA registration") + } else { + None + }; + if registration_error.is_some() && server.enabled { + server.enabled = false; + server.disabled_reason = Some(crate::McpServerDisabledReason::EmaRegistration); + } + server.auth = McpServerAuth::EmaAuth; + let oauth = server.oauth.get_or_insert_default(); + oauth.client_id = Some(self.client_id.clone()); + oauth.authorization_server_issuer = Some(self.authorization_server_issuer.clone()); + server.scopes = Some(self.scopes.clone()); + oauth.ema_registration = None; + oauth.ema_registration_error = registration_error; + server.oauth_resource = Some(self.resource.clone()); + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct MarketplaceConfig { + /// Last time Codex successfully added or refreshed this marketplace. + #[serde(default)] + pub last_updated: Option, + /// Git revision Codex last successfully activated for this marketplace. + #[serde(default)] + pub last_revision: Option, + /// Source kind used to install this marketplace. + #[serde(default)] + pub source_type: Option, + /// Source location used when the marketplace was added. + #[serde(default)] + pub source: Option, + /// Git ref to check out when `source_type` is `git`. + #[serde(default, rename = "ref")] + pub ref_name: Option, + /// Sparse checkout paths used when `source_type` is `git`. + #[serde(default)] + pub sparse_paths: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MarketplaceSourceType { + Git, + Local, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[cfg(test)] +#[path = "types_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/types_tests.rs b/codex-rs/config/src/types_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..3e1082a07bf8e8a67d6634d451ffcc276743496f --- /dev/null +++ b/codex-rs/config/src/types_tests.rs @@ -0,0 +1,106 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn deserialize_skill_config_with_name_selector() { + let cfg: SkillConfig = toml::from_str( + r#" + name = "github:yeet" + enabled = false + "#, + ) + .expect("should deserialize skill config with name selector"); + + assert_eq!(cfg.name.as_deref(), Some("github:yeet")); + assert_eq!(cfg.path, None); + assert!(!cfg.enabled); +} + +#[test] +fn deserialize_skill_config_with_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let cfg: SkillConfig = toml::from_str(&format!( + r#" + path = {path:?} + enabled = false + "#, + path = skill_path.display().to_string(), + )) + .expect("should deserialize skill config with path selector"); + + assert_eq!( + cfg, + SkillConfig { + path: Some( + AbsolutePathBuf::from_absolute_path(&skill_path) + .expect("skill path should be absolute"), + ), + name: None, + enabled: false, + } + ); +} + +#[test] +fn memories_config_clamps_count_limits_to_nonzero_values() { + let config = MemoriesConfig::from(MemoriesToml { + max_raw_memories_for_consolidation: Some(0), + max_rollouts_per_startup: Some(0), + ..Default::default() + }); + + assert_eq!( + config, + MemoriesConfig { + max_raw_memories_for_consolidation: 1, + max_rollouts_per_startup: 1, + ..MemoriesConfig::default() + } + ); +} + +#[test] +fn memories_config_clamps_rate_limit_remaining_threshold() { + let config = MemoriesConfig::from(MemoriesToml { + min_rate_limit_remaining_percent: Some(101), + ..Default::default() + }); + assert_eq!( + config, + MemoriesConfig { + min_rate_limit_remaining_percent: 100, + ..MemoriesConfig::default() + } + ); + + let config = MemoriesConfig::from(MemoriesToml { + min_rate_limit_remaining_percent: Some(-1), + ..Default::default() + }); + assert_eq!( + config, + MemoriesConfig { + min_rate_limit_remaining_percent: 0, + ..MemoriesConfig::default() + } + ); +} + +#[test] +fn memories_version_selects_pipeline_without_changing_other_defaults() { + for (source, version) in [ + ("", MemoryVersion::V1), + ("version = \"v2\"", MemoryVersion::V2), + ] { + let parsed: MemoriesToml = toml::from_str(source).expect("parse memories config"); + assert_eq!( + MemoriesConfig::from(parsed), + MemoriesConfig { + version, + ..Default::default() + } + ); + } + assert!(toml::from_str::("version = \"v3\"").is_err()); +} diff --git a/codex-rs/install-context/src/bundle_tests.rs b/codex-rs/install-context/src/bundle_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..cf49aa69b049fafe24345718443abd3128fc8b52 --- /dev/null +++ b/codex-rs/install-context/src/bundle_tests.rs @@ -0,0 +1,76 @@ +//! Ensure a provisioned CLI still discovers its outer package and install method. + +use super::*; +use pretty_assertions::assert_eq; +use std::fs; + +#[test] +fn bundle_executable_preserves_package_layout_and_install_method() -> std::io::Result<()> { + let home = tempfile::tempdir()?; + let package = home.path().join("packages/standalone/releases/test"); + let executable = package.join("CodexCLI.app/Contents/MacOS/codex"); + fs::create_dir_all(executable.parent().unwrap())?; + fs::write(&executable, "")?; + for directory in [BIN_DIRNAME, RESOURCES_DIRNAME, PATH_DIRNAME] { + fs::create_dir_all(package.join(directory))?; + } + fs::write( + package.join(PACKAGE_METADATA_FILENAME), + r#"{"version":"1.2.3"}"#, + )?; + let package = canonical_absolute_path(&package).unwrap(); + let bin_dir = package.join(BIN_DIRNAME); + let resources_dir = package.join(RESOURCES_DIRNAME); + let path_dir = package.join(PATH_DIRNAME); + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ true, + /*current_exe*/ Some(&executable), + /*method_override*/ None, + /*codex_home*/ Some(home.path()), + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Standalone { + release_dir: package.clone(), + resources_dir: Some(resources_dir.clone()), + platform: standalone_platform(), + }, + package_layout: Some(CodexPackageLayout { + package_dir: package, + bin_dir, + resources_dir: Some(resources_dir), + path_dir: Some(path_dir), + }), + } + ); + Ok(()) +} + +#[cfg(windows)] +#[test] +fn winget_root_requires_metadata_for_the_actual_executable() -> std::io::Result<()> { + let temp = tempfile::tempdir()?; + let package = canonical_absolute_path(temp.path()).unwrap(); + let name = "codex-x86_64-pc-windows-msvc.exe"; + let executable = package.join(name); + fs::write(&executable, "signed CLI")?; + for directory in [RESOURCES_DIRNAME, PATH_DIRNAME] { + fs::create_dir_all(package.join(directory))?; + } + assert_eq!(CodexPackageLayout::from_exe(executable.as_path()), None); + for entrypoint in ["other.exe", "bin/codex.exe", name] { + fs::write( + package.join(PACKAGE_METADATA_FILENAME), + serde_json::json!({"layoutVersion": 1, "entrypoint": entrypoint}).to_string(), + )?; + let expected = (entrypoint == name).then(|| CodexPackageLayout { + package_dir: package.clone(), + bin_dir: package.clone(), + resources_dir: Some(package.join(RESOURCES_DIRNAME)), + path_dir: Some(package.join(PATH_DIRNAME)), + }); + assert_eq!(CodexPackageLayout::from_exe(executable.as_path()), expected); + } + Ok(()) +} diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..1a9be86a1169e701e07d07152a9dd9a9886be000 --- /dev/null +++ b/codex-rs/install-context/src/lib.rs @@ -0,0 +1,915 @@ +use std::ffi::OsStr; +use std::path::Path; +use std::path::PathBuf; +use std::sync::OnceLock; + +use codex_utils_absolute_path::AbsolutePathBuf; +use semver::Version; +use serde::Deserialize; + +const BIN_DIRNAME: &str = "bin"; +const CODE_MODE_HOST_EXECUTABLE_NAME: &str = if cfg!(windows) { + "codex-code-mode-host.exe" +} else { + "codex-code-mode-host" +}; +const PACKAGE_METADATA_FILENAME: &str = "codex-package.json"; +const PATH_DIRNAME: &str = "codex-path"; +const RELEASES_DIRNAME: &str = "releases"; +const RESOURCES_DIRNAME: &str = "codex-resources"; +const STANDALONE_PACKAGES_DIRNAME: &str = "standalone"; +const ZSH_DIRNAME: &str = "zsh"; +static INSTALL_CONTEXT: OnceLock = OnceLock::new(); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StandalonePlatform { + Unix, + Windows, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CodexPackageLayout { + /// The package root that contains the metadata file and layout directories. + pub package_dir: AbsolutePathBuf, + /// Directory containing the Codex entrypoint executable. + pub bin_dir: AbsolutePathBuf, + /// Directory containing managed helper binaries and data files, when present. + pub resources_dir: Option, + /// Folder that should be prepended to the PATH, when present. + pub path_dir: Option, +} + +/// Version metadata recorded in a bundled Codex runtime package. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct CodexPackageManifest { + pub version: Version, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallContext { + pub method: InstallMethod, + pub package_layout: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InstallMethod { + Standalone { + /// The managed standalone release directory. Legacy installs use paths + /// such as + /// `~/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl`. + /// Package-layout installs use the package root that contains `bin/`, + /// `codex-resources/`, and `codex-path/`. + release_dir: AbsolutePathBuf, + /// The bundled resource directory for managed dependencies. + resources_dir: Option, + /// The platform of the standalone release, either `Unix` or `Windows`. + platform: StandalonePlatform, + }, + /// A Codex binary launched through the npm-managed `codex.js` shim. + Npm, + /// A Codex binary launched through the bun-managed `codex.js` shim. + Bun, + /// A Codex binary launched through the pnpm-managed `codex.js` shim. + Pnpm, + /// A Codex binary launched through the Vite+-managed `codex.js` shim. + VitePlus, + /// A Codex binary that appears to come from a Homebrew install prefix. + Brew, + /// Any other execution environment. + /// + /// This commonly covers `cargo run`, app-bundled Codex binaries, custom + /// internal launchers, and tests that execute Codex from an arbitrary path. + Other, +} + +impl InstallContext { + pub fn from_exe( + is_macos: bool, + current_exe: Option<&Path>, + method_override: Option, + ) -> Self { + let codex_home = codex_utils_home_dir::find_codex_home().ok(); + Self::from_exe_with_codex_home( + is_macos, + current_exe, + method_override, + codex_home.as_deref(), + ) + } + + fn from_exe_with_codex_home( + is_macos: bool, + current_exe: Option<&Path>, + method_override: Option, + codex_home: Option<&Path>, + ) -> Self { + let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); + let method = if let Some(method) = method_override { + method + } else if let Some(exe_path) = current_exe { + install_method_from_exe(exe_path, codex_home, package_layout.as_ref(), is_macos) + } else { + InstallMethod::Other + }; + + Self { + method, + package_layout, + } + } + + pub fn current() -> &'static Self { + INSTALL_CONTEXT.get_or_init(|| { + let current_exe = std::env::current_exe().ok(); + let method_override = if std::env::var_os("CODEX_MANAGED_BY_VITE_PLUS").is_some() { + Some(InstallMethod::VitePlus) + } else if std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some() { + Some(InstallMethod::Pnpm) + } else if std::env::var_os("CODEX_MANAGED_BY_NPM").is_some() { + Some(InstallMethod::Npm) + } else if std::env::var_os("CODEX_MANAGED_BY_BUN").is_some() { + Some(InstallMethod::Bun) + } else { + None + }; + Self::from_exe( + cfg!(target_os = "macos"), + current_exe.as_deref(), + method_override, + ) + }) + } + + /// Read the manifest for the package that contains the current executable. + pub fn package_manifest(&self) -> Option { + let package_layout = self.package_layout.as_ref()?; + let manifest = + std::fs::read_to_string(package_layout.package_dir.join(PACKAGE_METADATA_FILENAME)) + .ok()?; + serde_json::from_str(&manifest).ok() + } + + pub fn rg_command(&self) -> PathBuf { + if let Some(package_layout) = &self.package_layout + && let Some(path_dir) = &package_layout.path_dir + { + let bundled_rg = path_dir.join(default_rg_command()); + if bundled_rg.is_file() { + return bundled_rg.into_path_buf(); + } + } + + if let InstallMethod::Standalone { + resources_dir: Some(resources_dir), + .. + } = &self.method + { + let bundled_rg = resources_dir.join(default_rg_command()); + if bundled_rg.is_file() { + return bundled_rg.into_path_buf(); + } + } + + default_rg_command() + } + + pub fn code_mode_host_program(&self) -> PathBuf { + // prefer the one packed under codex-resources + self.bundled_resource(CODE_MODE_HOST_EXECUTABLE_NAME) + .map_or_else( + || self.code_mode_host_program_from_exe(std::env::current_exe().ok().as_deref()), + AbsolutePathBuf::into_path_buf, + ) + } + + fn code_mode_host_program_from_exe(&self, current_exe: Option<&Path>) -> PathBuf { + let executable_dir = if let Some(package_layout) = &self.package_layout { + Some(package_layout.bin_dir.clone()) + } else if let InstallMethod::Standalone { release_dir, .. } = &self.method { + Some(release_dir.clone()) + } else { + current_exe + .and_then(Path::parent) + .and_then(canonical_absolute_path) + }; + if let Some(executable_dir) = executable_dir { + let executable = executable_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + if executable.is_file() { + return executable.into_path_buf(); + } + } + + current_exe + .and_then(Path::parent) + .map(|parent| parent.join(CODE_MODE_HOST_EXECUTABLE_NAME)) + .unwrap_or_else(|| PathBuf::from(CODE_MODE_HOST_EXECUTABLE_NAME)) + } + + pub fn bundled_resource(&self, file_name: impl AsRef) -> Option { + if let Some(package_layout) = &self.package_layout + && let Some(resources_dir) = &package_layout.resources_dir + { + let resource = resources_dir.join(file_name.as_ref()); + if resource.is_file() { + return Some(resource); + } + } + + if let InstallMethod::Standalone { + resources_dir: Some(resources_dir), + .. + } = &self.method + { + let resource = resources_dir.join(file_name); + if resource.is_file() { + return Some(resource); + } + } + + None + } + + pub fn bundled_zsh_path(&self) -> Option { + if cfg!(windows) { + None + } else { + self.bundled_resource(zsh_resource_path()) + } + } + + pub fn bundled_zsh_bin_dir(&self) -> Option { + self.bundled_zsh_path()?.parent() + } +} + +impl CodexPackageLayout { + fn from_exe(exe_path: &Path) -> Option { + let canonical_exe = canonical_absolute_path(exe_path)?; + let exe_dir = canonical_exe.parent()?; + // WinGet preserves a target-qualified executable at the package root. + // Only recognize that layout when metadata names this exact executable. + #[cfg(windows)] + if let Ok(contents) = std::fs::read(exe_dir.join(PACKAGE_METADATA_FILENAME)) + && let Ok(metadata) = serde_json::from_slice::(&contents) + && metadata["layoutVersion"] == 1 + && metadata["entrypoint"].as_str().map(OsStr::new) == canonical_exe.file_name() + { + return Some(Self { + resources_dir: existing_dir(exe_dir.join(RESOURCES_DIRNAME)), + path_dir: existing_dir(exe_dir.join(PATH_DIRNAME)), + package_dir: exe_dir.clone(), + bin_dir: exe_dir, + }); + } + match exe_dir.file_name() { + Some(name) if name == OsStr::new(BIN_DIRNAME) => Self::from_package_bin_dir(exe_dir), + Some(name) if name == OsStr::new(RESOURCES_DIRNAME) => { + let package_dir = exe_dir.parent()?; + Self::from_package_bin_dir(package_dir.join(BIN_DIRNAME)) + } + Some(name) if name == OsStr::new("MacOS") => { + // A provisioned CLI keeps helpers and metadata in the outer + // package. current_exe points inside the bundle, not at bin/codex. + let contents = exe_dir.parent()?; + let bundle = contents.parent()?; + if contents.file_name()? != OsStr::new("Contents") + || bundle.file_name()? != OsStr::new("CodexCLI.app") + { + return None; + } + Self::from_package_bin_dir(bundle.parent()?.join(BIN_DIRNAME)) + } + Some(_) | None => None, + } + } + + fn from_package_bin_dir(bin_dir: AbsolutePathBuf) -> Option { + if !bin_dir.is_dir() { + return None; + } + let package_dir = bin_dir.parent()?; + if !package_dir.join(PACKAGE_METADATA_FILENAME).is_file() { + return None; + } + + Some(Self { + resources_dir: existing_dir(package_dir.join(RESOURCES_DIRNAME)), + path_dir: existing_dir(package_dir.join(PATH_DIRNAME)), + package_dir, + bin_dir, + }) + } +} + +fn install_method_from_exe( + exe_path: &Path, + codex_home: Option<&Path>, + package_layout: Option<&CodexPackageLayout>, + is_macos: bool, +) -> InstallMethod { + if let Some(standalone_method) = standalone_install_method(exe_path, codex_home, package_layout) + { + return standalone_method; + } + + if is_macos && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) { + InstallMethod::Brew + } else { + InstallMethod::Other + } +} + +fn standalone_install_method( + exe_path: &Path, + codex_home: Option<&Path>, + package_layout: Option<&CodexPackageLayout>, +) -> Option { + let canonical_codex_home = canonical_absolute_path(codex_home?)?; + let release_dir = if let Some(package_layout) = package_layout { + package_layout.package_dir.clone() + } else { + canonical_absolute_path(exe_path)?.parent()? + }; + let releases_root = canonical_codex_home + .join("packages") + .join(STANDALONE_PACKAGES_DIRNAME) + .join(RELEASES_DIRNAME); + if !release_dir.starts_with(releases_root.as_path()) { + return None; + } + + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + Some(InstallMethod::Standalone { + release_dir, + resources_dir: resources_dir.is_dir().then_some(resources_dir), + platform: standalone_platform(), + }) +} + +fn canonical_absolute_path(path: &Path) -> Option { + let canonical_path = std::fs::canonicalize(path).ok()?; + AbsolutePathBuf::from_absolute_path(canonical_path).ok() +} + +fn standalone_platform() -> StandalonePlatform { + if cfg!(windows) { + StandalonePlatform::Windows + } else { + StandalonePlatform::Unix + } +} + +fn existing_dir(path: AbsolutePathBuf) -> Option { + path.is_dir().then_some(path) +} + +fn default_rg_command() -> PathBuf { + if cfg!(windows) { + PathBuf::from("rg.exe") + } else { + PathBuf::from("rg") + } +} + +fn zsh_resource_path() -> PathBuf { + PathBuf::from(ZSH_DIRNAME).join(BIN_DIRNAME).join("zsh") +} + +#[cfg(test)] +#[path = "bundle_tests.rs"] +mod bundle_tests; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::fs; + + const TEST_RESOURCE_NAME: &str = "codex-test-helper"; + + #[test] + fn code_mode_host_program_prefers_package_resource_over_legacy_binary() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + let resource_host = resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "legacy host")?; + fs::write(&resource_host, "managed host")?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program(), + AbsolutePathBuf::from_absolute_path(resource_host.canonicalize()?)?.into_path_buf() + ); + Ok(()) + } + + #[test] + fn code_mode_host_program_uses_package_resource_without_legacy_binary() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + let resource_host = resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(&resource_host, "managed host")?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program(), + AbsolutePathBuf::from_absolute_path(resource_host.canonicalize()?)?.into_path_buf() + ); + Ok(()) + } + + #[test] + fn code_mode_host_program_ignores_resource_directory_and_uses_legacy_binary() + -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + let legacy_host = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(&legacy_host, "legacy host")?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program(), + AbsolutePathBuf::from_absolute_path(legacy_host.canonicalize()?)?.into_path_buf() + ); + Ok(()) + } + + #[test] + fn detects_standalone_install_from_release_layout() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let release_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&resources_dir)?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(release_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; + fs::write( + resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), + "managed host", + )?; + fs::write(resources_dir.join(default_rg_command()), "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; + let canonical_release_dir = + AbsolutePathBuf::from_absolute_path(release_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Standalone { + release_dir: canonical_release_dir.clone(), + resources_dir: Some(canonical_resources_dir.clone()), + platform: standalone_platform(), + }, + package_layout: None, + } + ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_release_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + assert_eq!( + context.code_mode_host_program(), + canonical_resources_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); + Ok(()) + } + + #[test] + fn standalone_rg_falls_back_when_resources_are_missing() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let release_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + fs::create_dir_all(&release_dir)?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!(context.rg_command(), default_rg_command()); + Ok(()) + } + + #[test] + fn detects_package_layout_independently_from_install_method() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write( + package_dir.path().join(PACKAGE_METADATA_FILENAME), + r#"{ + "layoutVersion": 1, + "version": "1.2.3", + "target": "x86_64-unknown-linux-musl", + "variant": "codex", + "entrypoint": "bin/codex", + "resourcesDir": "codex-resources", + "pathDir": "codex-path" +} +"#, + )?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + if !cfg!(windows) { + let zsh_path = resources_dir.join(zsh_resource_path()); + fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?; + fs::write(&zsh_path, "")?; + } + let canonical_package_dir = + AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + let package_layout = CodexPackageLayout { + package_dir: canonical_package_dir, + bin_dir: canonical_bin_dir.clone(), + resources_dir: Some(canonical_resources_dir.clone()), + path_dir: Some(canonical_path_dir.clone()), + }; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ None, + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Other, + package_layout: Some(package_layout), + } + ); + assert_eq!( + context.package_manifest(), + Some(CodexPackageManifest { + version: Version::new(1, 2, 3), + }) + ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_bin_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + assert_eq!( + context.code_mode_host_program(), + canonical_bin_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); + if cfg!(windows) { + assert_eq!(context.bundled_zsh_path(), None); + assert_eq!(context.bundled_zsh_bin_dir(), None); + } else { + assert_eq!( + context.bundled_zsh_path(), + Some(canonical_resources_dir.join(zsh_resource_path())) + ); + assert_eq!( + context.bundled_zsh_bin_dir(), + Some(canonical_resources_dir.join(ZSH_DIRNAME).join(BIN_DIRNAME)) + ); + } + Ok(()) + } + + #[cfg(unix)] + #[test] + fn code_mode_host_program_accepts_symlinks_to_files() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join("codex"); + let executable_target = package_dir.path().join("host-target"); + let executable_path = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME); + fs::write(&exe_path, "")?; + fs::write(&executable_target, "")?; + std::os::unix::fs::symlink(&executable_target, &executable_path)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + ); + + assert_eq!( + context.code_mode_host_program_from_exe(Some(&exe_path)), + canonical_bin_dir + .join(CODE_MODE_HOST_EXECUTABLE_NAME) + .into_path_buf() + ); + Ok(()) + } + + #[test] + fn standalone_package_layout_keeps_standalone_install_method() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let package_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + let bin_dir = package_dir.join(BIN_DIRNAME); + let resources_dir = package_dir.join(RESOURCES_DIRNAME); + let path_dir = package_dir.join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_package_dir = + AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ Some(codex_home.path()), + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Standalone { + release_dir: canonical_package_dir.clone(), + resources_dir: Some(canonical_resources_dir.clone()), + platform: standalone_platform(), + }, + package_layout: Some(CodexPackageLayout { + package_dir: canonical_package_dir, + bin_dir: canonical_bin_dir, + resources_dir: Some(canonical_resources_dir.clone()), + path_dir: Some(canonical_path_dir.clone()), + }), + } + ); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + assert_eq!( + context.bundled_resource(TEST_RESOURCE_NAME), + Some(canonical_resources_dir.join(TEST_RESOURCE_NAME)) + ); + Ok(()) + } + + #[test] + fn npm_managed_package_keeps_package_layout() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + + let context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ Some(InstallMethod::Npm), + ); + assert_eq!(context.method, InstallMethod::Npm); + assert!(context.package_layout.is_some()); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + Ok(()) + } + + #[test] + fn standalone_package_rg_falls_back_when_codex_path_is_missing() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ None, + ); + assert_eq!(context.rg_command(), default_rg_command()); + Ok(()) + } + + #[test] + fn bundled_file_lookups_ignore_directories() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?; + fs::create_dir_all(resources_dir.join(TEST_RESOURCE_NAME))?; + fs::create_dir_all(path_dir.join(default_rg_command()))?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + let fallback_exe_path = package_dir.path().join("fallback-codex"); + fs::write(&fallback_exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*method_override*/ None, + /*codex_home*/ None, + ); + assert_eq!( + context.code_mode_host_program_from_exe(Some(&fallback_exe_path)), + package_dir.path().join(CODE_MODE_HOST_EXECUTABLE_NAME) + ); + assert_eq!(context.rg_command(), default_rg_command()); + assert_eq!(context.bundled_resource(TEST_RESOURCE_NAME), None); + Ok(()) + } + + #[test] + fn code_mode_host_program_is_next_to_the_executable_even_when_missing() { + let context = InstallContext { + method: InstallMethod::Other, + package_layout: None, + }; + + assert_eq!( + context.code_mode_host_program_from_exe(Some(Path::new("/opt/codex/bin/codex"))), + PathBuf::from("/opt/codex/bin").join(CODE_MODE_HOST_EXECUTABLE_NAME) + ); + } + + #[test] + fn code_mode_host_program_falls_back_to_its_name_when_executable_is_unknown() { + let context = InstallContext { + method: InstallMethod::Other, + package_layout: None, + }; + + assert_eq!( + context.code_mode_host_program_from_exe(/*current_exe*/ None), + PathBuf::from(CODE_MODE_HOST_EXECUTABLE_NAME) + ); + } + + #[test] + fn package_manager_method_overrides_take_precedence() { + let vite_plus_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::VitePlus), + ); + assert_eq!( + vite_plus_context, + InstallContext { + method: InstallMethod::VitePlus, + package_layout: None, + } + ); + + let pnpm_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::Pnpm), + ); + assert_eq!( + pnpm_context, + InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + } + ); + + let npm_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::Npm), + ); + assert_eq!( + npm_context, + InstallContext { + method: InstallMethod::Npm, + package_layout: None, + } + ); + + let bun_context = InstallContext::from_exe( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*method_override*/ Some(InstallMethod::Bun), + ); + assert_eq!( + bun_context, + InstallContext { + method: InstallMethod::Bun, + package_layout: None, + } + ); + } + + #[test] + fn brew_is_detected_on_macos_prefixes() { + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ true, + /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), + /*method_override*/ None, + /*codex_home*/ None, + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Brew, + package_layout: None, + } + ); + } +} diff --git a/codex-rs/tui/assets/inline_visualization/visualize.css b/codex-rs/tui/assets/inline_visualization/visualize.css new file mode 100644 index 0000000000000000000000000000000000000000..81626e4d7f3b3adecde85621c78454607fe10f74 --- /dev/null +++ b/codex-rs/tui/assets/inline_visualization/visualize.css @@ -0,0 +1,867 @@ +:root { + color-scheme: light dark; + background-color: var( + --background, + var(--color-background-primary, light-dark(rgb(255 255 255), rgb(24 24 24))) + ) !important; + + /* Agent-facing contract; keep in sync with SKILL.md. */ + --background: var( + --color-background-primary, + light-dark(rgb(255 255 255), rgb(24 24 24)) + ); + --foreground: var( + --color-text-primary, + light-dark(rgb(26 28 31), rgb(255 255 255)) + ); + --card: color-mix(in oklab, var(--foreground) 5%, var(--background)); + --card-foreground: var( + --color-text-primary, + light-dark(rgb(26 28 31), rgb(255 255 255)) + ); + --popover: var( + --color-background-secondary, + light-dark(rgb(255 255 255), rgb(45 45 45)) + ); + --popover-foreground: var( + --color-text-primary, + light-dark(rgb(26 28 31), rgb(255 255 255)) + ); + --primary: var( + --color-text-info, + light-dark(rgb(51 156 255), rgb(131 195 255)) + ); + --primary-foreground: var( + --color-text-inverse, + light-dark(rgb(255 255 255), rgb(13 13 13)) + ); + --secondary: var( + --color-background-secondary, + light-dark(rgb(255 255 255 / 96%), rgb(54 54 54 / 96%)) + ); + --secondary-foreground: var( + --color-text-primary, + light-dark(rgb(26 28 31), rgb(255 255 255)) + ); + --muted: color-mix(in srgb, var(--foreground) 10%, transparent); + --muted-foreground: var( + --color-text-secondary, + light-dark(rgb(26 28 31 / 49.4%), rgb(255 255 255 / 49.8%)) + ); + --accent: var( + --color-background-info, + light-dark(rgb(229 242 255), rgb(13 39 63)) + ); + --accent-foreground: var( + --color-text-info, + light-dark(rgb(51 156 255), rgb(131 195 255)) + ); + --destructive: var( + --color-text-warning, + light-dark(rgb(226 85 7), rgb(255 133 73)) + ); + --border: var( + --color-border-secondary, + light-dark(rgb(26 28 31 / 8%), rgb(255 255 255 / 8.2%)) + ); + --input: var( + --color-border-primary, + light-dark( + rgb(26 28 31 / 11.8%), + color-mix(in oklab, rgb(0 0 0) 10%, transparent) + ) + ); + --ring: var( + --color-ring-primary, + light-dark(rgb(51 156 255), rgb(131 195 255 / 76%)) + ); + --font-size-base: var(--font-text-md-size, 14px); + --blue: light-dark(rgb(51 156 255), rgb(51 156 255)); + --orange: light-dark(rgb(226 85 7), rgb(251 106 34)); + --green: light-dark(rgb(0 162 64), rgb(64 201 119)); + --red: light-dark(rgb(224 46 42), rgb(255 103 100)); + --purple: light-dark(rgb(146 79 247), rgb(173 123 249)); + --yellow: light-dark(rgb(255 195 0), rgb(255 210 64)); + --viz-series-1: var(--primary); + --viz-series-2: light-dark(rgb(243 136 59), rgb(245 154 86)); + --viz-series-3: light-dark(rgb(93 201 119), rgb(116 213 139)); + --viz-series-4: light-dark(rgb(235 119 177), rgb(240 143 192)); + --viz-series-5: light-dark(rgb(155 121 236), rgb(170 145 239)); + --viz-series-6: light-dark(rgb(58 185 177), rgb(90 203 194)); + + /* Internal implementation variables; not part of the agent contract. */ + --font-sans: -apple-system, system-ui, "Segoe UI", sans-serif; + --font-mono: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + --font-size-normal: max(11px, var(--font-size-base)); + --font-size-tooltip: calc(var(--font-size-base) - 1px); + --font-size-small: max(11px, calc(var(--font-size-base) - 2px)); + --font-size-h1: calc(var(--font-size-normal) * 1.7142857143); + --font-size-h2: calc(var(--font-size-normal) * 1.4285714286); + --font-size-h3: calc(var(--font-size-normal) * 1.2857142857); + --font-weight-normal: 430; + --font-weight-medium: 500; + --line-height-normal: calc(var(--font-size-normal) * 1.5); + --line-height-tooltip: calc(var(--font-size-tooltip) * 1.4285714286); + --line-height-small: calc(var(--font-size-small) + 4px); + --radius: var(--border-radius-lg, 12.5px); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-2xl: calc(var(--radius) * 1.6); + --radius-full: 9999px; + --shadow-sm: 0 1px 2px -1px rgb(0 0 0 / 8%); + --checkmark-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 17 17'%3E%3Cpath d='M12.8961 3.64101C13.1297 3.41418 13.4984 3.37523 13.7779 3.56581C14.0571 3.75635 14.1554 4.11331 14.0299 4.41347L13.9615 4.53847L7.71151 13.7045C7.59411 13.8767 7.4063 13.9877 7.19881 14.0072C6.99136 14.0267 6.78564 13.9533 6.63826 13.806L2.88826 10.056L2.79842 9.9457C2.6192 9.67407 2.64927 9.30496 2.88826 9.06581C3.12738 8.82669 3.49647 8.79676 3.76815 8.97597L3.8785 9.06581L7.03084 12.2182L12.8053 3.74941L12.8961 3.64101Z'/%3E%3C/svg%3E"); + + /* Legacy aliases; not part of the current agent contract. */ + --viz-bg: transparent; + --viz-panel: var(--card); + --viz-border: var(--border); + --viz-text: var(--foreground); + --viz-muted: var(--muted-foreground); + --viz-accent: var(--primary); + --viz-accent-text: var(--primary-foreground); + --viz-accent-bg: var(--accent); + --viz-font-size: var(--font-size-base); + --viz-warning: var(--destructive); +} + +:root[data-theme="light"] { + color-scheme: light; +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html > body { + /* Preserve MCP aliases for older inline renderers without creating :root cycles. */ + --color-background-primary: var( + --background, + light-dark(rgb(255 255 255), rgb(24 24 24)) + ); + --color-text-primary: var( + --foreground, + light-dark(rgb(26 28 31), rgb(255 255 255)) + ); + --color-border-secondary: var( + --border, + light-dark(rgb(26 28 31 / 8%), rgb(255 255 255 / 8.2%)) + ); + + margin: 0; + padding: 5px; + color: var(--foreground); + background: transparent !important; + font-family: var(--font-sans); + font-size: var(--font-size-normal); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-normal); +} + +a { + color: color-mix(in srgb, var(--viz-accent) 80%, var(--viz-text) 20%); + cursor: pointer; + font-weight: var(--font-weight-medium, 500); + text-decoration: none; + text-underline-offset: 2px; +} + +a:is(:hover, :focus-visible) { + text-decoration-line: underline; + text-decoration-style: dashed; + text-decoration-thickness: 0.5px; +} + +h1, +h2, +h3, +h4, +h5, +h6, +p { + margin-block: 0; +} + +h1 { + font-size: var(--font-size-h1); + font-weight: var(--font-weight-medium); + line-height: 1.25; +} + +h2 { + font-size: var(--font-size-h2); + font-weight: var(--font-weight-medium); + line-height: 1.25; +} + +h3, +h4, +h5, +h6 { + font-size: var(--font-size-h3); + font-weight: var(--font-weight-medium); + line-height: 1.3; +} + +b, +strong, +th { + font-weight: var(--font-weight-medium); +} + +code:not(pre code) { + display: inline; + padding: 1px 6px; + border-radius: var(--radius-sm); + corner-shape: superellipse(1.5); + background: var(--muted); + font-family: var(--font-mono); + font-size: 0.92em; + overflow-wrap: anywhere; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + word-break: break-word; +} + +.table-responsive { + width: 100%; + overflow-x: auto; + scrollbar-width: thin; +} + +.table { + width: 100%; + border-collapse: collapse; + color: var(--foreground); + font: inherit; + text-align: start; +} + +.table :is(th, td) { + padding-block: 10px; + padding-inline: 0 24px; + overflow-wrap: anywhere; + border-bottom: 1px solid var(--border); + text-align: start; + vertical-align: top; +} + +.table-responsive > .table :is(th, td) { + overflow-wrap: break-word; +} + +.table :is(th, td):last-child { + padding-inline-end: 0; +} + +.table :is(caption, thead th) { + font-weight: 600; +} + +.table thead th { + padding-block: 8px; + border-bottom-color: color-mix(in srgb, var(--foreground) 16%, transparent); +} + +.table tbody tr:last-child :is(th, td) { + border-bottom: 0; +} + +.table.table-sm :is(th, td) { + padding-block: 6px; +} + +.table.table-sm :is(th, td):not(:last-child) { + padding-inline-end: 16px; +} + +.table :is(.text-end, [align="right"]) { + text-align: end; + font-variant-numeric: tabular-nums; +} + +.table :is(.text-center, [align="center"]) { + text-align: center; +} + +.table .text-nowrap { + white-space: nowrap; +} + +#widget { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 0; + background: transparent !important; +} + +.card { + min-width: 0; + padding: 12px; + overflow: hidden; + overflow-wrap: break-word; + border-radius: var(--radius-2xl); + corner-shape: superellipse(1.5); + color: var(--card-foreground); + background: var(--card); +} + +#widget > :not(.card) { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +.tooltip { + position: fixed; + z-index: 50; + top: 0; + left: 0; + width: max-content; + max-width: min( + 20rem, + var(--tooltip-available-width, calc(100vw - 10px)), + calc(100vw - 10px) + ); + max-height: min( + var(--tooltip-available-height, calc(100vh - 10px)), + calc(100vh - 10px) + ); + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + corner-shape: superellipse(1.5); + color: var(--popover-foreground); + background: var(--popover); + box-shadow: none; + font-size: var(--font-size-tooltip); + line-height: var(--line-height-tooltip); + overflow-wrap: break-word; + white-space: normal; + pointer-events: none; + user-select: none; +} + +.viz-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(max(180px, 24%), 1fr)); + gap: 10px; +} + +.viz-stat { + display: flex; + flex-direction: column; + gap: 2px; +} + +.viz-stat-value { + font-size: var(--font-size-h2); + font-weight: var(--font-weight-medium); + line-height: 1.25; +} + +.viz-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.viz-badge { + padding: 3px 8px; + border-radius: var(--radius-full); + color: var(--accent-foreground); + background: var(--accent); + font-weight: var(--font-weight-medium); +} + +small, +.text-small, +.viz-badge { + font-size: var(--font-size-small); + line-height: var(--line-height-small); +} + +.text-muted { + color: var(--muted-foreground); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.viz-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.viz-controls > .form-label { + display: grid; + min-width: min(100%, 260px); + flex: 1 1 280px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 2px 12px; + margin-bottom: 0; +} + +.viz-controls > .form-label > :is(.form-control, .form-range, .form-select) { + grid-column: 1 / -1; +} + +.btn, +.form-check-input, +.form-control, +.form-range, +.form-select { + font: inherit; +} + +.btn { + appearance: button; + display: inline-flex; + inline-size: fit-content; + max-inline-size: 100%; + min-height: 28px; + align-items: center; + justify-content: center; + gap: 4px; + margin: 0; + padding: 0 8px; + -webkit-app-region: no-drag; + border: 1px solid var(--input); + border-radius: var(--radius-lg); + corner-shape: superellipse(1.5); + color: var(--secondary-foreground); + background: var(--secondary); + cursor: var(--cursor-interaction, pointer); + text-align: center; + text-decoration: none; + white-space: nowrap; + user-select: none; + -webkit-font-smoothing: antialiased; +} + +.btn:is(.btn-block, .viz-tile) { + inline-size: 100%; +} + +.btn.viz-tile { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +a.btn { + cursor: pointer; + text-decoration: none; +} + +.btn:not(:disabled):hover { + background: color-mix(in srgb, var(--foreground) 6%, var(--secondary)); +} + +.btn-primary { + border-color: transparent; + color: var(--primary-foreground); + background: var(--foreground); + background-clip: padding-box; +} + +.btn-primary .text-muted { + color: color-mix(in srgb, var(--primary-foreground) 50%, transparent); +} + +.btn-primary:not(:disabled):hover { + background: color-mix(in srgb, var(--foreground) 80%, transparent); + background-clip: padding-box; +} + +.btn-ghost { + border-color: transparent; + color: var(--muted-foreground); + background: transparent; +} + +.btn-ghost:not(:disabled):hover { + color: var(--foreground); + background: color-mix(in srgb, var(--foreground) 6%, var(--secondary)); +} + +.btn:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +[data-lucide] { + stroke-width: 1.6; +} + +.form-label { + display: block; + margin-bottom: 6px; + color: var(--foreground); +} + +.form-control { + display: block; + width: 100%; + min-height: 28px; + padding: 0 8px; + outline: none; + border: 1px solid var(--input); + border-radius: var(--radius-lg); + corner-shape: superellipse(1.5); + color: var(--foreground); + background: var(--secondary); +} + +.form-control::placeholder { + color: var(--muted-foreground); +} + +.form-control[type="file"] { + padding: 0; + overflow: hidden; + cursor: var(--cursor-interaction, pointer); +} + +.form-control[type="file"]::file-selector-button { + min-height: 26px; + margin-right: 8px; + padding: 0 8px; + border: 0; + border-right: 1px solid var(--input); + color: var(--secondary-foreground); + background: var(--secondary); + cursor: inherit; + font: inherit; +} + +.form-control[type="file"]:not(:disabled):hover::file-selector-button { + background: color-mix(in srgb, var(--foreground) 6%, var(--secondary)); +} + +.form-control-color[type="color"] { + width: 40px; + height: 28px; + padding: 3px; + cursor: var(--cursor-interaction, pointer); +} + +.form-control-color[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.form-control-color[type="color"]::-webkit-color-swatch { + border: 0; + border-radius: calc(var(--radius-lg) - 4px); + corner-shape: superellipse(1.5); +} + +textarea.form-control { + height: auto; + min-height: 72px; + padding: 8px 10px; + resize: vertical; +} + +.form-control:focus-visible { + border-color: var(--ring); + box-shadow: inset 0 0 0 1px var(--ring); +} + +.form-control:disabled, +.form-select:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.form-select { + appearance: none; + display: block; + width: 100%; + min-height: 28px; + margin: 0; + padding: 0 32px 0 8px; + outline: none; + border: 1px solid var(--input); + border-radius: var(--radius-lg); + corner-shape: superellipse(1.5); + color: var(--foreground); + background-color: var(--secondary); + background-image: + linear-gradient(45deg, transparent 50%, var(--muted-foreground) 50%), + linear-gradient(135deg, var(--muted-foreground) 50%, transparent 50%); + background-position: + calc(100% - 14px) 50%, + calc(100% - 10px) 50%; + background-repeat: no-repeat; + background-size: 4px 4px; + cursor: var(--cursor-interaction, default); +} + +.form-select:not(:disabled):hover { + background-color: color-mix(in srgb, var(--foreground) 6%, var(--secondary)); +} + +.form-select:focus-visible { + border-color: var(--ring); + box-shadow: inset 0 0 0 1px var(--ring); +} + +.form-check { + display: flex; + min-height: 20px; + align-items: center; + gap: 6px; +} + +.form-check-input { + appearance: none; + width: 14px; + height: 14px; + flex: 0 0 auto; + margin: 0; + border: 1px solid var(--input); + color: var(--primary-foreground); + background-color: transparent; + cursor: var(--cursor-interaction, default); + transition: + background-color 150ms, + border-color 150ms, + box-shadow 150ms; +} + +.form-check:not(.form-switch) .form-check-input[type="checkbox"] { + border-color: var(--input); + border-radius: var(--radius-sm); + corner-shape: superellipse(1.5); + background-color: var(--secondary); + box-shadow: var(--shadow-sm); +} + +.form-check:not(.form-switch) + .form-check-input:not(:disabled):not(:checked):hover { + background-color: var(--card); +} + +.form-check:not(.form-switch) .form-check-input[type="checkbox"]:checked { + border-color: var(--primary); + background-color: var(--primary); +} + +.form-check:not(.form-switch) + .form-check-input[type="checkbox"]:checked::before { + display: block; + width: 100%; + height: 100%; + background: var(--primary-foreground); + content: ""; + mask: var(--checkmark-image) center / 12px 12px no-repeat; +} + +.form-check-input[type="radio"] { + width: 14px; + height: 14px; + border-radius: var(--radius-full); +} + +.form-check-input[type="radio"]:checked { + border: 2px solid var(--primary); + background: + radial-gradient(circle, var(--primary-foreground) 0 2.5px, transparent 3px), + var(--primary); +} + +.form-check:not(.form-switch) .form-check-input:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.form-check:not(.form-switch) .form-check-input:disabled { + cursor: not-allowed; + pointer-events: none; +} + +.form-check:not(.form-switch) .form-check-input:disabled + .form-check-label { + cursor: not-allowed; +} + +.form-switch .form-check-input:disabled, +.form-switch .form-check-input:disabled + .form-check-label { + cursor: not-allowed; + opacity: 0.6; +} + +.form-check-label { + color: var(--foreground); + cursor: var(--cursor-interaction, default); +} + +.form-switch .form-check-input { + position: relative; + width: 32px; + height: 20px; + border: 0; + border-radius: var(--radius-full); + background: var(--muted); + box-shadow: none; + transition: background-color 200ms cubic-bezier(0, 0, 0.2, 1); +} + +.form-switch .form-check-input::before { + position: absolute; + top: 50%; + left: 0; + width: 16px; + height: 16px; + box-sizing: border-box; + border: 1px solid light-dark(var(--primary-foreground), var(--foreground)); + border-radius: var(--radius-full); + background: light-dark(var(--primary-foreground), var(--foreground)); + box-shadow: var(--shadow-sm); + content: ""; + transform: translate(2px, -50%); + transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); +} + +.form-switch .form-check-input:checked { + background: var(--primary); +} + +.form-switch .form-check-input:checked::before { + transform: translate(14px, -50%); +} + +.form-switch .form-check-input:focus-visible { + box-shadow: 0 0 0 2px var(--ring); +} + +.form-range { + appearance: none; + display: block; + width: 100%; + height: 28px; + flex: 1; + margin: 0; + padding: 0; + outline: none; + border: 0; + accent-color: var(--primary); + background: linear-gradient( + color-mix(in srgb, var(--foreground) 7%, transparent), + color-mix(in srgb, var(--foreground) 7%, transparent) + ) + center / 100% 2px no-repeat; +} + +.form-range::-webkit-slider-runnable-track { + height: 28px; + background: transparent; +} + +.form-range::-webkit-slider-thumb { + appearance: none; + width: 20px; + height: 20px; + margin-top: 4px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: light-dark(var(--primary-foreground), var(--foreground)); +} + +.form-range:focus-visible::-webkit-slider-thumb { + border-color: var(--ring); + box-shadow: inset 0 0 0 1px var(--ring); +} + +.form-range::-moz-range-track { + height: 28px; + background: transparent; +} + +.form-range::-moz-range-thumb { + width: 20px; + height: 20px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: light-dark(var(--primary-foreground), var(--foreground)); +} + +.form-range:focus-visible::-moz-range-thumb { + border-color: var(--ring); + box-shadow: inset 0 0 0 1px var(--ring); +} + +.form-range:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.btn:not(.btn-primary, .viz-tile):is( + [aria-pressed="true"], + [aria-selected="true"], + .is-selected + ) { + border-color: var(--primary); + color: var(--primary-foreground); + background: var(--primary); +} + +.btn.viz-tile:is([aria-pressed="true"], [aria-selected="true"], .is-selected) { + border-color: var(--primary); + box-shadow: inset 0 0 0 1px var(--primary); +} + +.btn:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +svg { + display: block; + max-width: 100%; + height: auto; +} + +#widget > svg { + width: 100%; +} + +/* Model-authored chart rules can otherwise stretch icons after these styles load. */ +svg.lucide { + display: block; + width: 16px !important; + height: 16px !important; + flex: none; + margin: 0 !important; + stroke-width: 1.6; +} + +.text-warning, +.text-destructive { + color: var(--destructive); +} diff --git a/codex-rs/tui/assets/inline_visualization/visualize.html b/codex-rs/tui/assets/inline_visualization/visualize.html new file mode 100644 index 0000000000000000000000000000000000000000..01ef405324dcef83c93a7fbdcc71d1fd391ff9a4 --- /dev/null +++ b/codex-rs/tui/assets/inline_visualization/visualize.html @@ -0,0 +1,239 @@ + + + + + + diff --git a/codex-rs/tui/frames/blocks/frame_1.txt b/codex-rs/tui/frames/blocks/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..8c3263f51841c1c72f6737306cc84c62e1cb9f80 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_1.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▒██▒▒██▒ + ▒▒█▓█▒█▓█▒▒░░▒▒ ▒ █▒ + █░█░███ ▒░ ░ █░ ░▒░░░█ + ▓█▒▒████▒ ▓█░▓░█ + ▒▒▓▓█▒░▒░▒▒ ▓░▒▒█ + ░█ █░ ░█▓▓░░█ █▓▒░░█ + █▒ ▓█ █▒░█▓ ░▒ ░▓░ + ░░▒░░ █▓▓░▓░█ ░░ + ░▒░█░ ▓░░▒▒░ ▓░██████▒██ ▒ ░ + ▒░▓█ ▒▓█░ ▓█ ░ ░▒▒▒▓▓███░▓█▓█░ + ▒▒▒ ▒ ▒▒█▓▓░ ░▒████ ▒█ ▓█▓▒▓ + █▒█ █ ░ ██▓█▒░ + ▒▒█░▒█▒ ▒▒▒█░▒█ + ▒██▒▒ ██▓▓▒▓▓▓▒██▒█░█ + ░█ █░░░▒▒▒█▒▓██ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_10.txt b/codex-rs/tui/frames/blocks/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..a6fbbf1a4b87f51033516a2822284194b2a125ca --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_10.txt @@ -0,0 +1,17 @@ + + ▒████▒██▒ + ██░███▒░▓▒██ + ▒▒█░░▓░░▓░█▒██ + ░▒▒▓▒░▓▒▓▒███▒▒█ + ▓ ▓░░ ░▒ ██▓▒▓░▓ + ░░ █░█░▓▓▒ ░▒ ░ + ▒ ░█ █░░░░█ ░▓█ + ░░▒█▓█░░▓▒░▓▒░░ + ░▒ ▒▒░▓░░█▒█▓░░ + ░ █░▒█░▒▓▒█▒▒▒░█░ + █ ░░░░░ ▒█ ▒░░ + ▒░██▒██ ▒░ █▓▓ + ░█ ░░░░██▓█▓░▓░ + ▓░██▓░█▓▒ ▓▓█ + ██ ▒█▒▒█▓█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_11.txt b/codex-rs/tui/frames/blocks/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..88e3dfa7c58380a9f823011a3d329d9f65562f75 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_11.txt @@ -0,0 +1,17 @@ + + ███████▒ + ▓ ▓░░░▒▒█ + ▓ ▒▒░░▓▒█▓▒█ + ░▒▒░░▒▓█▒▒▓▓ + ▒ ▓▓▒░█▒█▓▒░░█ + ░█░░░█▒▓▓░▒▓░░ + ██ █░░░░░░▒░▒▒ + ░ ░░▓░░▒▓ ░ ░ + ▓ █░▓░░█▓█░▒░ + ██ ▒░▓▒█ ▓░▒░▒ + █░▓ ░░░░▒▓░▒▒░ + ▒▒▓▓░▒█▓██▓░░ + ▒ █░▒▒▒▒░▓ + ▒█ █░░█▒▓█░ + ▒▒ ███▒█░ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_12.txt b/codex-rs/tui/frames/blocks/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..c6c0ef3e87dcbfea92d628e164dbba0d6557935a --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_12.txt @@ -0,0 +1,17 @@ + + █████▓ + █▒░▒▓░█▒ + ░▓▒██ + ▓█░░░▒▒ ░ + ░ █░░░░▓▓░ + ░█▓▓█▒ ▒░ + ░ ░▓▒░░▒ + ░ ▓█▒░░ + ██ ░▓░░█░░ + ░ ▓░█▓█▒ + ░▓ ░ ▒██▓ + █ █░ ▒█░ + ▓ ██░██▒░ + █▒▓ █░▒░░ + ▒ █░▒▓▓ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_13.txt b/codex-rs/tui/frames/blocks/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..7a090e51e330a76811b404fc3d1c58f0ba8c2d51 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_13.txt @@ -0,0 +1,17 @@ + + ▓████ + ░▒▒░░ + ░░▒░ + ░██░▒ + █ ░░ + ▓▓░░ + █ ░░ + █ ░ + ▓█ ▒░▓ + ░ █▒░ + █░▓▓ ░░ + ░▒▒▒░ + ░██░▒ + █▒▒░▒ + █ ▓ ▒ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_14.txt b/codex-rs/tui/frames/blocks/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..f5e74d12b7ef8d2245d8c6a4d800c16909b701a3 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_14.txt @@ -0,0 +1,17 @@ + + ████▓ + █▓▒▒▓▒ + ░▒░░▓ ░ + ░░▓░ ▒░█ + ░░░▒ ░ + ░█░░ █░ + ░░░░ ▓ █ + ░░▒░░ ▒ + ░░░░ + ▒▓▓ ▓▓ + ▒░ █▓█░ + ░█░░▒▒▒░ + ▓ ░▒▒▒░ + ░▒▓█▒▒▓ + ▒█ █▒▓ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_15.txt b/codex-rs/tui/frames/blocks/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..f04599ea27dc8756f6b179d47e142a71f29e9789 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_15.txt @@ -0,0 +1,17 @@ + + █████░▒ + ░█▒░░▒▓██ + ▓▓░█▒▒░ █░ + ░▓░ ▓▓█▓▒▒░ + ░░▒ ▒▒░░▓ ▒░ + ▒░░▓░░▓▓░ + ░░ ░░░░░░█░ + ░░▓░░█░░░ █▓░ + ░░████░░░▒▓▓░ + ░▒░▓▓░▒░█▓ ▓░ + ░▓░░░░▒░ ░ ▓ + ░██▓▒░░▒▓ ▒ + █░▒█ ▓▓▓░ ▓░ + ░▒░░▒▒▓█▒▓ + ▒▒█▒▒▒▒▓ + ░░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_16.txt b/codex-rs/tui/frames/blocks/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..1eb080286ec588dcd7c979349f9c0d7d6ed4eee9 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_16.txt @@ -0,0 +1,17 @@ + + ▒▒█ ███░▒ + ▓▒░░█░░▒░▒▒ + ░▓▓ ▒▓▒▒░░ █▒ + ▓▓▓ ▓█▒▒░▒░░██░ + ░░▓▒▓██▒░░█▓░░▒ + ░░░█░█ ░▒▒ ░ ░▓░ + ▒▒░ ▓░█░░░░▓█ █ ░ + ░▓▓ ░░░░▓░░░ ▓ ░░ + ▒▒░░░█░▓▒░░ ██ ▓ + █ ▒▒█▒▒▒█░▓▒░ █▒░ + ░░░█ ▓█▒░▓ ▓▓░░░ + ░░█ ░░ ░▓▓█ ▓ + ▒░█ ░ ▓█▓▒█░ + ▒░░ ▒█░▓▓█▒░ + █▓▓▒▒▓▒▒▓█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_17.txt b/codex-rs/tui/frames/blocks/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..dd5f5c8da5ffb8fd78f896745c4b1ed5c63034c3 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_17.txt @@ -0,0 +1,17 @@ + + █▒███▓▓░█▒ + ▒▓██░░░█▒█░█ ▒█ + ██▒▓▒▒▒░██ ░░░▒ ▒ + ▓░▓▒▓░ ▒░ █░▓▒░░░▒▒ + ░▓▒ ░ ░ ▓▒▒▒▓▓ █ + ░▒██▓░ █▓▓░ ▓█▒▓░▓▓ + █ ▓▓░ █▓▓░▒ █ ░░▓▒░ + ▓ ▒░ ▓▓░░▓░█░░▒▓█ + █▓█▓▒▒▒█░▒▒░▒▒▓▒░░░ ░ + ░ ▒▓▒▒░▓█▒▓░░▒ ▒███▒ + ▒▒▒▓ ████▒▒░█▓▓▒ ▒█ + ▒░░▒█ ░▓░░░ ▓ + ▒▒▒ █▒▒ ███▓▒▒▓ + █ ░██▒▒█░▒▓█▓░█ + ░█▓▓▒██░█▒██ + ░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_18.txt b/codex-rs/tui/frames/blocks/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..a6c93e6c01d294d9c4d3376c50028decd258719e --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_18.txt @@ -0,0 +1,17 @@ + + ▒▒▒█▒▒█▓░█▒ + ▒█ ▒▓███░▒▒█ █▓▓▒ + ▒▓▓░█ █▒ █ ▓▒ █▓▓▒ █ + █░░█▓█▒ █ █▒░▒▓▒░▒▓▒▒▒█ + ▒▒▓▓ ▓░ ▒ █▒▒▓░▓░▒▒▓▒▒▒ + ▓▒░ ██░▓▒▒▒▓███░█▓▓▒▓░▓░ + ░░▒▓▓ █▓█▓░ ▒▓ █░▒░▒█ + ▒▓░░ ▒▒ ░░▓▒ ░▓░ + ▒ █▒▒▒▓▒▓█░░█░█▓▒█ ░█░░ + ▒▒▒░█▒█ ░░▓▒▒▒▒░░░▒▓░░▒ █ + ░▓░▒░ █████░ ▒▒▒▓░▓█▓░▓░ + ▒▒ █▒█ ░░█ ▓█▒█ + ▒▒██▒▒▓ ▒█▒▒▓▒█░ + █░▓████▒▒▒▒██▒▓▒██ + ░░▒▓▒▒█▓█ ▓█ + ░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_19.txt b/codex-rs/tui/frames/blocks/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..73341b5d58168519146b80e7eb32fc2f33b6af32 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_19.txt @@ -0,0 +1,17 @@ + + ▒▒▒▒█░█▒▒░▓▒ + ▒█░░░▒▓▒▒▒▒█▒█░███ + ██▓▓▓ ░██░ ░█▓█░█▓▒ + ▓▓░██▒░ ▒▒▒██▒░██ + ░░▓░▓░ █░▒ ▓ ░▒ ░▒█ + ░▒▓██ ▒░█░▓ ▓▓ █▓█░ + ▒▒░░█ ▓█▒▓░██░ ▓▓▓█░ + ░░░░ ░▓ ▒░ █ ░ ░░░ + ░█░▒█▒▓▓▒▒▒░░░░██▓█░▓ ▒ ░░ + ▒▓▓█░▒█▓▒██▒█░█ ▒▒ ▓▒▒▒█▓▓░▒ + █▒ ▓█░ ██ ▒▒▒▓░▓▓ ▓▓█ + ▒▒▒█▒▒ ░▓▓▒▓▓█ + █ ▒▒░░██ █▓▒▓▓░▓░ + █ ▓░█▓░█▒▒▒▓▓█ ▓█░█ + ░▓▒▓▓█▒█▓▒█▓▒ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_2.txt b/codex-rs/tui/frames/blocks/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..1c7578c970ef11e8b3bda592c530f692b3d344a2 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_2.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▒█▒▒▒██▒ + ▒██▓█▓█░░░▒░░▒▒█░██▒ + █░█░▒██░█░░ ░ █▒█▓░░▓░█ + ▒░▓▒▓████▒ ▓█▒░▓░█ + █▒ ▓█▒░▒▒▒▒▒ ▒█░▒░█ + █▓█ ░ ░█▒█▓▒█ ▒▒░█░ + █░██░ ▒▓░▓░▒░█ ▓ ░ ░ + ░ ▒░ █░█░░▓█ ░█▓▓░ + █ ▒░ ▓░▒▒▒░ ▓░█████████░▒░░█ + ▒▒█░ ▓░░█ ▓█ ░▒▒▒▒▒▒▓▓▒▒░█▓ ░ + ▒▒▒ █ █▒▓▓░█ ░ ███████ ░██░░ + █▒▒▓▓█ ░ ██▓▓██ + ▓▒▒▒░██ █▒▒█ ▒░ + ░░▒▓▒▒ ██▓▓▒▓▓▓▒█░▒░░█ + ░████░░▒▒▒▒░▓▓█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_20.txt b/codex-rs/tui/frames/blocks/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..3e0c5f0d9ceb48fc1127016c965255ce69f7933d --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_20.txt @@ -0,0 +1,17 @@ + + ▒▒█▒░░▒█▒█▒▒ + █▓▒ ▓█▒█▒▒▒░░▒▒█▒██ + ██ ▒██ ░█ ░ ▒ ▒██░█▒ + ▒░ ▒█░█ ▒██░▒▓█▒▒ + ▒░ █░█ ▒▓ ▒░░▒█▒░░▒ + ▓░█░█ ███▓░ ▓ █▒░░▒ + ▓░▓█░ ██ ▓██▒ █▒░▓ + ░▒▒▓░ ▓▓░ █ ░░ ░ + ░▓░░▓█▒▓▒▒▒▒▒▒▒██▓▒▒▒▒█ ▓ ░▒ + █░▒░▒ ▓░░▒▒▒▒░▒ █▒▒ ░▒▒ █▓ ░░ + ▒█▒▒█ █ ▒█▒░░█░ ▓▒ + █ ▒█▓█ ▒▓█▓░▓ + ▒▒▒██░▒ █▓█░▓██ + ▒█▓▓ ░█▒▓▓█▓ ░ ░█▓██ + ░██░▒ ▒▒▒▒▒░█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_21.txt b/codex-rs/tui/frames/blocks/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..971877651f3eb03d2d567d924af0651b3a1ecbac --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_21.txt @@ -0,0 +1,17 @@ + + ▒▒█▒█▒▒█▒██▒▒ + ███░░▒▒█░▒░█▓▒░▓██▒ + ▓█▒▒██▒ ░ ░▒░██▒░██ + ██░▓ █ ▒█▓██▓██ + ▓█▓█░ █░▓▒▒ ▒▒▒▒█ + ▓ ▓░ ███▒▓▓ ▒▒▒█ + ░█░░ ▒ ▓░█▓█ ▒▓▒ + ░▒ ▒▓ ░█ ░ ░ + ░ ░ ██▓▓▓▓▓███ ▒░█ ░█ ▓▓ ░ + ░ ░▒ ░▒ ▒█░ ▒ ░█░█ ▓ ▓▓ + ▓ ▓ ░░ █░ ██▒█▓ ▓░ █ + ██ ▓▓▒ ▒█ ▓ + █▒ ▒▓▒ ▒▓▓██ █░ + █▒▒ █ ██▓░░▓▓▒█ ▓░ + ███▓█▒▒▒▒█▒▓██░ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_22.txt b/codex-rs/tui/frames/blocks/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..2713fd669e240ae69ee3b99b406848cda84c553c --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_22.txt @@ -0,0 +1,17 @@ + + ▒██▒█▒▒█▒██▒ + ▒█▓█░▓▒▓░▓▒░░▓░█▓██▒ + █▓█▓░▒██░ ░ █▒███▒▒██ + ▓█░██ ██░░░▒█▒ + ▒░░▓█ █▒▓░▒░▓▓▓█░ + ▒░█▓░ █░▓░▓▒▓░ ▒░▒▒░ + ░██▒▓ ░█░▒█▓█ ░░▓░ + ░░▒░░ ░▒░░▒▒ ░▒░ ░ + ░░█ █ █░▒▒▓▓▓▒██▒▒█░▒ ▒█ ▒░▓ + ▒░▒ █▒▒▒█ ▓█ ░▓▓░ ▒█▓▒ ░██ ▓▒▒ + ▒▒▒▒░ ██ ░ ░▓██▒▓▓▓ █░ + ▒█▒▒▒█ ▒██ ░██ + █ █▓ ██▒ ▒▓██ █▒▓ + █▓███ █░▓▒█▓▓▓▒█ ███ + ░ ░▒▓▒▒▒▓▒▒▓▒█░ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_23.txt b/codex-rs/tui/frames/blocks/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..39a6c55644463f657ac7e0fa16edbf03413d574f --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_23.txt @@ -0,0 +1,17 @@ + + ▒██▒▒████▒█▒▒ + ▒▒░█░▒▒█▒▒▒█░▒░█░█▒ + █ █░██▓█░ ░▓█░▒▓░░█ + ▓▓░█▓▓░ ▒▓▓▒░░▓▒ + ▓▓░░▓█ █▓████▓█▒░▒ + █▒░ ▓░ ▒█████▓██░░▒░█ + ░░░ ░ ▓▓▓▓ ▒░░ ░██ + ░▓░ ░ ░ ░█▒▒█ ░ █▓░ + ▒ ▒ ░█░▓▒▒▒▒▒▓▒░▒█░▒ ▒▒ ░ ░░░ + ░▒▒▒░ ▒ ▓░▒ ▒░▒▒█░ ▒▒░ + ▓█░ ░ ░ █░▓▓▒░▒▓▒▓░ + █░░▒░▓ █▓░▒▒▓░ + ▒ ░██▓▒▒ ▒▓ ▓█▓█▓ + ▒▒▒█▓██▒░▒▒▒██ ▓▒██░ + ░ █▒▒░▒▒█▒▒██░ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_24.txt b/codex-rs/tui/frames/blocks/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..90ccc262f0770d71c5a1f001080bea1211a90c2a --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_24.txt @@ -0,0 +1,17 @@ + + ▒░▒▓███▒▒█▒ + █ ▒▓ ░▒▒░▒▒██▒██ + █ █▓▒▓█ ░ ▓░▓█░███ ▒ + ██▓▓█▓░▒█▒░░▓░ ▒█▒░▒▒█ + █ ▓▓▒▓█ ░ ▓▒▒░░░▒░██ + ░█▒█▒░ ███▓ ▓░▓ ▓ ▒ + ░ ░░ █▓▒█▓ ▓▒▒░▒▒░▒ + ░ ▒░░ ░█▒▓▒▒░░▒▓▓░░░ + ░▓ ░▓▓▓▓██░░░██▒██▒░ ░ ░░ + ▒ ▓ █░▓██▓▓██░▓▒▒██░ ░█░ + ▒ █▒░▒█ ░ ▒█▓█▒░▒▓█░ + ▒ ▒██▒ ░ ▓▓▓ + ▒▓█▒░░▓ ▒▒ ▒▓▓▒█ + ▓▓██▒▒ ░░▓▒▒▓░▒▒▓░ + █▓▒██▓▒▒▒▒▒██ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_25.txt b/codex-rs/tui/frames/blocks/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..d8fd5b45a8f98a81ae6d3292674b9543238602df --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_25.txt @@ -0,0 +1,17 @@ + + ▒█▒█▓████▒ + █ ███░▒▓▒░█░░█ + ▓░▓▓██ ▓░█▒▒▒░░░▒ + ░██░ ▓ ▒░ ▒░██▒▓ + █▒▒▒█▓█▒▓▓▒░ ░▓▓▒▓█ + ▒█░░░▒██▓▒░▓ ▓░█░▓▓░█ + ░▓░█░ ░▒▒▓▒▒▓░▒▓▒ ░▒░ + ░░░▓░▓ ░▒▒▒▓░▒▒░▒░░▒ + ▒█▒░ ░▒▒▒▒▒▒█░░▒▒░██░▒ + ▓▓ ░▓░█░▒░░▓█▒░▒█▒▓▒░ + ▒░█▓▒░░ ██▓░▒░▓░░ + ░▒ ░▓█▓▒▓██▓▒▓█▓▓░▓ + ▒░▒░▒▒▒█▓▓█▒▓▒░░▓ + ▒▓▓▒▒▒█▒░██ █░█ + ░█ █▒██▒█░█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_26.txt b/codex-rs/tui/frames/blocks/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..a4734b4486d896887a0390a79784699f33bff41e --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_26.txt @@ -0,0 +1,17 @@ + + ▒▓███ ██ + ▓█░▓▓▒░█▓░█ + ▓█ ░▓▒░▒ ▒█ + ▓█ █░░░▒░░▒█▓▒ + ░▒█▒░▓░ █▒▓▓░▒▓ + ▒ ░▓▓▓ █▒▒ ▒▒▓ + ░ ██▒░░▓░░▓▓ █ + ▓▓ ▒░░░▒▒▒░░▓░░ + ░ ▓▒█▓█░█▒▒▓▒░░ + ▓▒░▓█░▒▒██▒▒█░ + ░░ ▓░█ ▒█▓░█▒░░ + ▒▒░░▓▒ ▓▓ ░░░ + █ █░▒ ▒░▓░▓█ + ░ █▒▒ █▒██▓ + ▒▓▓▒█░▒▒█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_27.txt b/codex-rs/tui/frames/blocks/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..b99e90e6d43bc12c35ed58711d29e70c9fbecdba --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_27.txt @@ -0,0 +1,17 @@ + + ▓█████ + ░▓▓▓░▓▒ + ▓█░ █░▓█░ + ░░░▒░░▓░░ + ░ ░░▒▓█▒ + ░▒▓▒ ░░░░░ + ▒ ░░▒█░░ + ░ ░░░░▒ ░░ + ░▓ ▓ ░█░░░░ + █▒ ▓ ▒░▒█░░ + ░▓ ▒▒███▓█ + ░░██░░▒▓░ + ░▒▒█▒█▓░▒ + ▒▒▒░▒▒▓▓ + █▒ ▒▒▓ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_28.txt b/codex-rs/tui/frames/blocks/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..de6db173b4641e6dc2ce929f688a24c2e1cd1379 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_28.txt @@ -0,0 +1,17 @@ + + ▓██▓ + ░█▒░░ + ▒ ▓░░ + ░▓░█░ + ░ ░░ + ░ ▓ ░ + ▒░░ ▒░ + ░▓ ░ + ▓▒ ▒░ + ░░▓▓░░ + ░ ▒░ + ░▒█▒░ + ░▒█░░ + █▒▒▓░ + ░ ▓█░ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_29.txt b/codex-rs/tui/frames/blocks/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..d7b871c9c337044397f0b28a08cc10c178ebed3a --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_29.txt @@ -0,0 +1,17 @@ + + ██████ + █░█▓ █▒ + ▒█░░ █ ░ + ░░░░▒▒█▓ + ▒ ░ ░ ░ + ░█░░░ ▒▒ + ░▒▒░░░ ▒ + ░░▒░░ + ░░░█░ ░ + ▒░▒░░ ░ + █░░▓░▒ ▒ + ░▓░░░ ▒░ + ░░░░░░▒░ + ░▒░█▓ ░█ + ░░█ ▓█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_3.txt b/codex-rs/tui/frames/blocks/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..833b2b3db2e29f4ae16d12e6058f1cb77e0c1e35 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_3.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓██▒▒▒▒█▒ + ▒██▓▒░░█░ ▒▒░▓▒▒░██▒ + █▓▓█▓░█ ░ ░ ░ ███▓▒░█ + ▓█▓▒░▓██▒ ░▒█ ░░▒ + █▓█░▓▒▓░░█▒▒ ▒▒▒░░▒ + ▓░▒▒▓ ▓█░▒▓▒▒ ░ ▒▒░ + ▒█ ░ ██▒░▒ ░█ ▓█▓░█ + █▓░█░ █▓░ ▓▒░ ░▒░▒░ + ▓ █░ ▓░██░░█▓░▒██▒▒▒██▒░▒ ▓░ + █▒▓▒█ ▓▓█▓▓▓░ ░█░▒▒█ ▒▓█▓▒░░▒░░ + █▒░ ░ ░░██ ███ ███▓▓▓█▓ + ██░ ▒█ ░ ▓▒█▒▓▓ + ▒▒▓▓█▒█ ██▓▓ █░█ + ▒▒██▒██▒▒▓▒▓█▓▒█▓░▒█ + ░███▒▓░▒▒▒▒░▓▓▒ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_30.txt b/codex-rs/tui/frames/blocks/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c27cf67d0fa066fecec86489e7447801d9a710e --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_30.txt @@ -0,0 +1,17 @@ + + ▒▓ ████ + ▒▓▓░░▒██▒▒ + █▒░█▒▒░██▒ + ░░▒░▓░▒▒░▒ ▒█ + ▒█░░░▒░█░█ ░ + ░█░▒█ █░░░░▓░ + ▒▓░░░▒▒ ▒▓▒░ ▒░ + ░ ██▒░█░ ░▓ ░ + ░▒ ▒░▒░▒▓░█ ░ + ░░▒░▒▒░░ ██ ░ + ▒░░▓▒▒█░░░█░░ + ░█▓▓█▓█▒░░ ░ + ▒░▒░░▓█░░█░▓ + █▒██▒▒▓░█▓█ + ▒▓▓░▒▒▒▓█ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_31.txt b/codex-rs/tui/frames/blocks/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..c787451d71c98f64c56f1f4327b6b4321e37459d --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_31.txt @@ -0,0 +1,17 @@ + + ▒▓▓████▒█ + ▒██▓██▒ █▒███ + █░▒▓▓█▒▒░▓ ░▒█▒ + █░▓█▒▒█▓▒█▒▒░▒░░▒ + ▒░░░░█▓█▒▒█ ▒░▓▒▒ + ▓░▒░░▒░█ ▒▓██▓▓░█ ░ + ▓░░ ░▒█░▒▓▒▓▓█░█░▓░ + ▒▒█ ░░ ░▒ ░▒ ░░▒▓░ + ░▒█▒░█▒░░░▓█░░░▒ ░ + ░░░▓▓░░▒▒▒▒▒░▒░░ █ + ▒█▒▓█░█ ▓███░▓░█░▒ + ░░░▒▒▒█ ▒▒█ ░ + ▓░█▒▒ █ ▓ ░█░▓░ + ▓░▒░▓▒░░█░ █░░ + █ ▒░▒██▓▓▓█ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_32.txt b/codex-rs/tui/frames/blocks/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..e5e7adf64d409774369d4e19d43516c694fa9503 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_32.txt @@ -0,0 +1,17 @@ + + █████▓▓░█▒ + ▓█░██░▒░░██░░█ + ▓▒█▒▒██▒▓▓░█░█▒███ + █▓▓░▒█░▓▓ ▓ █▒▒░██ █ + ▓▓░█░█▒██░▓ █░█░▒▓▒█▒█ + ▒▓▒▒█▒█░░▓░░█▒ ░█▓ █ + █░ ▓█░█▒░░██░█▒░▓▒▓▓░█▒ + ░░░█▒ ▒░░ ▓█░▓▓▒ ▒░ ░ + ▒░░▓▒ █▒░ ▒▒░███░░░▒░ ▒░ + █ ▒░░█▒█▒▒▒▒▒▒░░█░▓░▓▒ + █▒█░░▓ ░█ ███▒▓▓▓▓▓▓ + ▒█░▒▒▒ █▒░▓█░ + ███░░░█▒ ▒▓▒░▓ █ + ▒▓▒ ░█░▓▒█░▒█ ▒▓ + ░▓▒▒▒██▓█▒ + ░░░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_33.txt b/codex-rs/tui/frames/blocks/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..31a607b29cb07121bb52471924a30b84502dffda --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_33.txt @@ -0,0 +1,17 @@ + + ▒██▒█▒█▒░▓▒ + ▒██░░▒█▒░▓░▓░█░█▓ + ▒▓▒░████▒ ░ █▓░░█ █ + █▒▓░▓▒░█▒ █░░▒▒█ + ▒▓░▓░░░▓▒▒▒ ░█▒▒▒ + ▓▓█ ▒▒▒▒░▒█ ▓▒▓▒▒ + ░░█ ▒██░▒░▒ ░█░░ + █░██ ███▒▓▒█ ▒ ░█ + ░░░ ░ █░ ▓████▓▒▒█░░█▓▒░▒░ + ▒▓░█ ▓▓█▓░░░▒▒▒▒▒░░█▒▒▒░░▓ + ▒▒▒█ ░▓░▓ ▓ ███ ░░█▓▒░ + ▒█▒██ █ ▓▓▓▓▒▓ + █▒ ███▓█ ▒█░█▓█▒█ + ▒░ █▒█░█▓█▒ ▓█▒█░█ + ▒▒██▒▒▒▒██▓▓ + ░░░░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_34.txt b/codex-rs/tui/frames/blocks/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..db99cb73d6193fef3b0b61c1a2760f6c774a1816 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_34.txt @@ -0,0 +1,17 @@ + + ▒█▒████▒░█▒ + ▒███▓▒▓░ ░██▒██▓█▒▒ + ▒▓▓█░█ ▓░█░ ░▒▒▒█ ███ + █▓▒░█▒▓█▒ █░██▒▒ + ▓▓░▒▓▓░ ░ █ ▒▒█▒▒ + █▓▒░░▓ ▒▒ ░▒█▒ ▒█▒░▒ + ░█▒░▒ █▒▒█░▒▒ ░▓░▒ + ▒░▒ ▓ ░█▒░▓ ░ ▓ ▒▒ + ██▓▓ ▓▒▓▓ ▒▒▒██████░▒▒ ░▒░ + ░░▒█▓██▒ ▓▓█░░░▒░▓▒▒▒█▓▒░░░░▒ + ▓▒▒█ ░▒░█▒ ██░░░░▒ █▓█▒░█ + ▓█▒▓▒▒▒ ▓▓▓░▓█ + ▒█░░█▒▓█ ▒█▒ ▒▓█░ + ▓▒▓░ ░██▓██▒█▒█░██▓█ + ░▒▓▒▒▒▒▒▒▓▒█▒▒ + ░░░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_35.txt b/codex-rs/tui/frames/blocks/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..814188563de96ff90b57ec3a00c78cd4f7df5b69 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_35.txt @@ -0,0 +1,17 @@ + + ▒██▓▒███▒██▒ + ██▒█▓░███ ░█░▓ ░█▒▒ + ▒▓▓░▓██░▒█ ░ ░ █▒█▓ ░██ + █▓▓█▓█▓█▒ ██▒▒░▒ + ▓▓░░▓▓▒ ▒██ ░▒█░█ + ▓▓▓▓█░ █░▒ ▓▓█▒ ░▒▒░ + ▒ ▓▓ ▒▒ ██▒▓ ░▒▒▒ + ░░░▓ ▓▒▒▓▓█ ▓ ▓ + ▓ █▒ █░░▓▓ ▓░▒▒▒▓▒▒█░░ ░░▒█ + ░█▒▓█ ▓▓▓ ██▓░▓ ▒█▒▒▒▒▓ ░▓█ ░█ + ▓░▒██▓▒▒░▓▒░ ░ ▒▒▒▒█▒▒█▓▓▒█░ + ▓▒▒▓░ ▒▓█ █▒ + ▒▓░▒▓█▓█ █▓▓▒███ + ▒▒ ░█░▓▓░░█░▓▓█ ▒▓▓ + ▒░▓▒▒▒▓▒▒███ ▒ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_36.txt b/codex-rs/tui/frames/blocks/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..cde83b56f419853ef51d604b6cd5a86ecafd909b --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_36.txt @@ -0,0 +1,17 @@ + + ▒▒█▒████▒██▒ + ▒▒ ▒█▓▓▓█▒█▓██ ███▒ + █▒█▒███▓█ ░░ ░ █░██░██░█ + ▒░ ██▒▒▒▒ ██░▒ ░ + █▓▒▓▒█░▒░▒█▓ ▒▒▓█ + ▓ █▓░ █▒ ░▓█ ▒▒█ + ░ ▓ ░ ▒ ▒▒ ░▒░█ + ░░▒░ ▒▒ ▒▓▓ ▒░ ░ + ░█ ░ ▓▓ ██ ████▒█████▒ ░▒░░ + ▒█░▒ █░▒▒▓░▓ ░░▒▒▒▒▒▒▒░░ ▒▓█░ + █ █░▒ █▒█▓▒ ██▒▒▒▒▒ ░█ ▓ + ██ ▒▓▓ █▓░ ▓ + ▒▓░░█░█ ███ ▓█░ + ██▒ ██▒▒▓░▒█░▓ ▓ █▓██ + ░██▓░▒██▒██████ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_4.txt b/codex-rs/tui/frames/blocks/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..7ad27d16e743826bd333b6fccce4089f7b37eb0a --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_4.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓█▒▒█▒██▒ + ▒▓ ██░▓ ░▒▒▓█▓░ ▓██▒ + ██▒░░░██ ░ ░▒░▒▒█░▓▒▒▒ + ▓▓░█░ ▓██ ░██▒█▒ + ▓░▓▒░▒░▒▓▒░█ ▒ ▒░█▒ + ▓░░▒░ █▒░░▓█▒ █ ▒▒░█ + ▒░▓░ ███▒█ ░█ █ ▓░ + ░▓▒ █░▓█▒░░ ░░░ + ▒░ ░▒ ▓░▓ ▒▓▓█░███▒▒▒▒██ ░░█ + ░▒▓ ░ █▓▓▓█▒░░▒▒░█▓▒█▓▓▒▓░▓▓ ░ + ░░▓█▒█▒▒█▒▓ ████████▒▓░░░░ + █░▒ ░▒░ █▒▓▓███ + ▒▒█▓▒ █▒ ▒▓▒██▓░▓ + ░░░▒▒██▒▓▓▒▓██▒██▒░█░ + █▒▒░▓░▒▒▒▒▒▓▓█░ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_5.txt b/codex-rs/tui/frames/blocks/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..24f984395481d9187e2faa4ef6efdc41cffb8705 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_5.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▓█▒▒▒██▒ + ▒█ █▓█▓░░█░▒█▓▒░ ██ + █▒▓▒█░█ ░ ▒▒░█▒ ███ + █░▓░▓░▓▒█ ▓▒░░░░▒ + █▒▓█▓▒▒█░▒▒█ ░ ▒░▒░▒ + ░░░░▓ ▒▒░▒▓▓░▒ █▓░░ + ░▓░ █ ░▒▒░▒ ░█ ██░█░█ + ░▓░▒ █▒▒░▓▒░ █░▒░ + ░█░▒█ ▓▒░ █░█▒▒░█░▒▒▒██▒ ░▓░ + ▒▒░▒██▓██ ░ ▓▓▒▒▒█▒▓█▓░▓█░░ + ▒█░░█░█▒▒▓█░ ██ █░▓░▒▓ + ▒▒█▓▒▒ ░ ▓▒▓██▒ + ▒▓█▒░▒█▒ ▒▒████▓█ + ▒░█░███▒▓░▒▒██▒█▒░▓█ + ▒▓█▒█ ▒▒▒▓▒███░ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_6.txt b/codex-rs/tui/frames/blocks/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..fe185a75737c8aca18c5d09923d7af04fc551303 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_6.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▓█▒▒██▒▒ + █▒▓▓█░▒██░██▓▒███▒ + ███░░░█ ░ ░▓▒███▓▒▒ + ▓█░█░█▒▒█ ▒█░░░░█ + █▒░░░█▒▒██▒ ▓▒▒░▒█ + ▓▓▓░▓░▒█▓░▒▒░█ ▓▒▒▓░ + ▒ █░░ ▒▒░▓▒▒ ▒█░▒░ + ░ ░░░ ▒░▒░▓░░ ░█▒░░ + ▒▓░▓░ ▓█░░█▓▓█▒░█░▒▒██▒▓▒▓░ + ░░▒█▓▒▒▒▓█ ░▓▒██░░█▓▒▒▒░█░▒ + ▓ ░ ▓░░░▓▓ █ ██ ░▒▒▓░ + █ ▓ ▓█░ █▓▒▓▓░░ + ▓░▒▒███ ▒█▒▒▓███ + ░ ░██ █ ▓░▒▒████ ▓▓█ + ▒▓▓███▒▒▒░▒███ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_7.txt b/codex-rs/tui/frames/blocks/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..7441f97e96e5dae3d1332348e4f6bc057d072021 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_7.txt @@ -0,0 +1,17 @@ + + ▒▓░▓██▒▒██▒ + ██░█▒░███▒▒▒▓ ░██ + █ █░░░█░ ░▒░░ █▓▒██ + ▒▒░░░░▓█ ▒░▒█░▓█ + ░█░█░░▒░▓▒█ ▓ █░░▒ + ░ ▓░░ ░█▒▓░▒ █▓░░░ + ░▒ ░ ▒▒░▒░▒░ ██▒░░ + ▒ ▓░░ ▒█▓░█░░ █ ░░░ + ▓ ░█ █ ▒▓░▒▓░░▓▓▒░░▒▓█▒░░ + ░██░░▒▓░░▓█░▓▒░░▒▒█▒█▓▒░▒░ + ▒ ▒▒▓█░█▒▓ ██████ ▒▓░░ + █▒ ▓▒▓▒░ █ ▓▓▓▓█ + █▓██▒▒▒▒ █▒░██▓██ + ▒▒█▒░█▒▓░▒▒▒██░██▓ + ░█ ░▓░▒▒█▒▓██ + \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_8.txt b/codex-rs/tui/frames/blocks/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..ea88b095382b79c19454fcc22e6afc79f49c2521 --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_8.txt @@ -0,0 +1,17 @@ + + ▒▒█▒▓██▒██▒ + █ █▓░░░█▒▒ ░ █ + ▒░▒█░▓▓█ █ ░▓░█▒█▒█ + ▒█▒█▓░██░ █ ▒▒░░▒ + █ ▓░▓█▒░▓▒ ▓█▒░░█ + ░██░▒▒▒▒▒░▒█ ▒█░░░ + ░█░░░ █▒▓▒░░░ ░▒░▓░█ + ▒█░░▓ ░█▒▓░██▓ ▓░▓░░ + ▒ ▒░░▒▒ ▓█▒░░▓█████▒░░░ + ▒█▓▒▒░ █░█░░▓░▒▒▒░░▒█ + ▓▓▒▒░▒░░░▓█▒█▒█ ▒█ ▓▒░ + ██ ░▒░░░ ▓█▓▓▓█ + █▒▒█▒▒▒▒ ▒▓▒▒░█▓█ + ▓▓█░██ ▓▓██▓▓▒█░░ + ░░▒██▒░▒██▓▒░ + ░░ \ No newline at end of file diff --git a/codex-rs/tui/frames/blocks/frame_9.txt b/codex-rs/tui/frames/blocks/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..9066ba1beda28807d4bef9411a05e24ea1003f0b --- /dev/null +++ b/codex-rs/tui/frames/blocks/frame_9.txt @@ -0,0 +1,17 @@ + + ▓▒▒█▓██▒█ + ▓█▒▓░░█ ▒ ▒▓▒▒ + ▓ █░░▓█▒▒▒▓ ▒▒░█ + ░░▓▓▒▒ ▒▒█░▒▒░██ + ▓█ ▓▒█ ░██ █▓██▓█░░ + ░ ░░░ ▒░▒▓▒▒ ░█░█░░░ + ░ ░█▒░██░▒▒█ ▓█▓ ░░░ + ░ ░▓▒█▒░░░▒▓▒▒▒░ ░░ + █░ ▓░ ░░░░█░░█░░░ + ░▒░░░▒█░▒░▒░░░░▒▒░░░ + ░▒▓▒▒░▓ ████░░ ▓▒░ + ▒░░░▒█░ █▓ ▒▓░░ + ▒█▒░▒▒ ▓▓▒▓░▓█ + ▒▓ ▒▒░█▓█▒▓▓█░░ + █▓▒ █▒▒░▓█▓ + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_1.txt b/codex-rs/tui/frames/codex/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..63249f42421157a5086d03746e8ac7ec3c974567 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_1.txt @@ -0,0 +1,17 @@ + + eoeddccddcoe + edoocecocedxxde ecce + oxcxccccee eccecxdxxxc + dceeccooe ocxdxo + eedocexeeee coxeeo + xc ce xcodxxo coexxo + cecoc cexcocxe xox + xxexe oooxdxc cex + xdxce dxxeexcoxcccccceco dc x + exdc edce oc xcxeeeodoooxoooox + eeece eeoooe eecccc eccoodeo + ceo co e ococex + eeoeece edecxecc + ecoee ccdddddodcceoxc + ecccxxxeeeoedccc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_10.txt b/codex-rs/tui/frames/codex/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..fe5e51b98459e9251e237249cd99fc2bbfdf21b7 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_10.txt @@ -0,0 +1,17 @@ + + eccccecce + ccecccexoeco + eeoxxoxxoxceoo + xeeoexdeoeocceeo + o dxxcxe cooeoxo + xe cxcxooe eecx + e xcccxxxxc xoo + c xxecocxxoeeoexx + c xe eexdxxcecdxx + x oxeoxeoeceeexce + o cxxxxxcc eocexe + eecoeocc exccooo + xc xxxxcodooxoe + deccoxcde ooc + co eceeodc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_11.txt b/codex-rs/tui/frames/codex/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..48e507a84a1b4d4b9dedfa5e9d4dc1f8dbf1742f --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_11.txt @@ -0,0 +1,17 @@ + + occcccce + oc dxxxeeo + oceexxdecoeo + xeexxddoedoo + ecodexcecdexxo + xcexxceddxeoxx + cc oxxxxxxexde + x xxoxxeo xcx + o cxoxxcocxex + cc exodocoxexe + ceo xxxxdoxeex + eeooxecoccdxe + e cxeeeexdc + ec cxxoeoce + ee cccece + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_12.txt b/codex-rs/tui/frames/codex/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..29de69516a39a327754e819136c24bfd014f7051 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_12.txt @@ -0,0 +1,17 @@ + + ccccco + odeeoxoe + c xoeco + ocxxxddcx + x cxxxxoox + xcoocecexc + x xoexxe + x ocexxc + co xoxxcxx + x oxcdce + xo xcdcco + o cx eox + o ccxocex + ceocoxexe + e cxeoo + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_13.txt b/codex-rs/tui/frames/codex/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..67fe336a13745dc7ee1ad1ff5fb631a3374cdd76 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_13.txt @@ -0,0 +1,17 @@ + + occco + xeexx + xeexc + xccxe + c xx + cdoxx + o xx + c cx + oc exo + xc cdx + ceoo xe + xeeex + xcoxe + ceexd + o ocd + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_14.txt b/codex-rs/tui/frames/codex/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..f8d32cd6d19d44dea0d4f8f7cba56b882ab5ce7b --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_14.txt @@ -0,0 +1,17 @@ + + ccccd + ooeeoe + xexxo x + xxoxcexo + xxxe x + xcxx cx + xxxx o c + xxexe e + xxxx c + ceoo do + exccooox + xcxxeeex + o cxddde + xeoceeo + ec cdo + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_15.txt b/codex-rs/tui/frames/codex/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..2e14341237a288f0a929e610d8247a6dde877ab3 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_15.txt @@ -0,0 +1,17 @@ + + cccccxe + eodxxedco + ooxcdexccx + xoe ooooeex + xxdcdexxocex + exxoxxoox c + xx xxxxxxox + xxoxxcxxx cox + xxcoocxxxeodx + xexdoxexco ox + xoxxxxex e d + xccoexxeo d + cxeo oooe de + xexxeeoceo + eeceeeeo + ee \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_16.txt b/codex-rs/tui/frames/codex/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..c90ce92cb6d058cf5bddbb2884ed1b215efa7822 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_16.txt @@ -0,0 +1,17 @@ + + edcccccxe + oexxcxxexde + xooceodexx ce + ooo dceexexxccx + xxdeoccdxxcoxee + xxxcxc xed x xox + eex oeoxxxxocco x + xod xexxoxxxcd ex + eexxxcxoexxccc o + cceeoddecxoex oex + xxxcccocexdcdoxxe + xxc xe eooo o + exc x oooeox + exxcecxoocex + cdoeddeedc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_17.txt b/codex-rs/tui/frames/codex/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..e1f2bb6d96c180925d587d1068d4e6a62694332f --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_17.txt @@ -0,0 +1,17 @@ + + odcccddxoe + edccxxxcdcxoceo + oceoeddecocxxxece + oxoeoxcee cxdexxxde + xoe x xcoedeoo o + edcooe odox oodoxoo + c dox oooxe ccxxodx + ocdx ooxxoxoxxddc + oocoeddcxeexeedexxx x + xcedeexoceoxxe eccce + eeeoccccccceexcooe ec + exxec eoxxe d + eee cee ocooeeo + o xccdeceedcdxc + ecdoeocxcecc + e \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_18.txt b/codex-rs/tui/frames/codex/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..be64251770d9de7997395ff95ef03d40b37fd591 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_18.txt @@ -0,0 +1,17 @@ + + eddcddcdxoe + eccedoccxeeoccdde + eodxcccdcocoeccooe c + oxxcooecc ceeeodxedeeeo + eeoo ox ecceeoxoxeedeee + oex ooxoeeeoocoxcooeoeox + xxedo cocoxceoccxdxdo + ceoxx eecxxde xdxc + ecc oedddddcxxoxcoeo xcxe + eeexcec xxoeeeexxxedxee o + xoxeeccccccce eeeoxocoeoe + ee oeo eeccocec + eecceeo eceeoeoe + cxoccccdddecceoeoc + cxxeoeeooccdcc + e \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_19.txt b/codex-rs/tui/frames/codex/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..89041571213b56b06b1e4c8058b45a659c2e5f64 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_19.txt @@ -0,0 +1,17 @@ + + eeddcxcddxoe + ecxxxeodddeceoxcoo + ocddocxcce ecdoecde + odxcoee eddcoexco + xxoeoe oxecocxe xeo + xeocc excxo oo cocx + edxxc oceoxcoe odocx + xxxx xdcexco x xxx + xcxeoddddddxxxxccdcxd e cxx + edooxdcoecceoeo ee deeeoooxe + cecocxcccccccc eeeoxoo ooc + eeecee eooeooc + c eexxco oddooxde + ccoxcoxceeddocc dcxc + cxoedoceooecoe + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_2.txt b/codex-rs/tui/frames/codex/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..a3c0663db464cf620fa79e64082797e84fb8e4f2 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_2.txt @@ -0,0 +1,17 @@ + + eoeddcdddcoe + ecoocdcxxxdxxdecxcce + oxcxeccxcee eccdcoxxdxo + exoeoccooe ooexoxo + oecocexeeeee eoxexo + cocce xcecoec eexcx + oxccx eoxdxexo ocxcx + xc ee oxcxxdc xcoox + cccdx dxeeexcoxccccccccoxexxc + edcx oxxc oc xdeeeeeooeexco x + eee c ceooxc ecccccccccxocxx + ceeooo e ocdooc + oeeexco odec exc + exedeecccdddddodceexxc + eccccxxeeeexdocc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_20.txt b/codex-rs/tui/frames/codex/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..cea5393f75858a8497a6cc9dc0dee00b9e78e08b --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_20.txt @@ -0,0 +1,17 @@ + + eecdxxdcdoee + oddcdoeodddxxeececo + oocecccxcc ecececcxce + excecxc eocxeocee + ex oxc eo exxecexxe + oeoxc cccdxco cexxe + dxdcx oc occe oexo + xeeoe ccddxco xxcx + xoxxdoddddddddeocdeeeec o xe + cxexec oeeeeeexe ceecxde oo xx + eoeecccccccccc eodxxox oe + c ecoo eocoxo + eeecoxe odcedcc + eooocxceddodcxceoocc + eccxe deeeexccc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_21.txt b/codex-rs/tui/frames/codex/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..efa6d610d9fb1ec4eb1270a704fd2b5a62ed98cb --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_21.txt @@ -0,0 +1,17 @@ + + eeodcddcdcoee + occeeeecxdxcdeeocce + dceeccece eexcceeco + ocxdcc eodcodco + oooce oxoee eeeeo + ocox occeoo eeeo + xcxe e oeooc edec + ee ed cxo x x + x x ocdddddccc exocxo do x + x xe xe eox ececxo ocoo + d co eeccc ce cceod oe o + cc dde ecc o + ce eoe eodcc oe + cde ccccdxxdddccc oe + cccdceeeeoedcce + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_22.txt b/codex-rs/tui/frames/codex/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..91c9c2ecaaec864b5a335ee16b2a70c7fabea8c1 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_22.txt @@ -0,0 +1,17 @@ + + eocdcddcdcoe + ecocxoeoxoexxdxcocce + odcdxecce ecceccceeco + dcxccc ooxxxece + exxoc oeoxdxoodcx + excoe oxoxdeoe exedx + xcceo xcxecoc xxox + xxdxe xexxee xexcx + xxoco cxddddddcceecxe eo exdc + exd ceeeo oocxoox ecdecxoo oed + eeeex cccccccce edcceooocoe + eceeeo ecocxoc + cccd cce eococceo + cdccoccxddcddodccccoc + cxcxedeeeodeodce + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_23.txt b/codex-rs/tui/frames/codex/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..5b5f1be139ddbdcaccfb9f14b5cc1dc905e36c2a --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_23.txt @@ -0,0 +1,17 @@ + + eocedccccdcee + edxcxeeoeddoxexcxce + occxcodce cxdcxedxxo + odxcdoe eddexxde + ooxeoc ooooccocexe + oexcoe ecccccoccxxexo + exxcx odoo exe c xcc + xox x xcxoeeo x cox + ece xcxddddddddxecxecee x xxx + xeeexcdc oee exeeox eex + ocx x eccccccc ceoddxeoeoe + oxxexo ooxeeoe + e xocoee eocdcoco + edecdccexddecccoecce + cx cdexeeceecce + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_24.txt b/codex-rs/tui/frames/codex/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..c0269d8eda63c5d56cf14d610f53a2721827b30e --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_24.txt @@ -0,0 +1,17 @@ + + exedcccddoe + oceocxeexddcoecc + occdeoccx oedcxcco e + ocooooxdoeexoe ecexeec + o ooeoo eccoeexeeexoc + xoecee cooo oxd oce + x xx ooeoocoeexeexe + x exx xodoeexxeooexx + xo xddddccxxxccecoex x xx + e o cxoooddooxoeeccx xcx + e cexeccccccce eoocexdooe + e eoce x codo + eoceexo edceodec + oocoeecxxddddxeeoe + cdeccdeeeddcc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_25.txt b/codex-rs/tui/frames/codex/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..5b040665d0b79a3fda19edb053006b6adc734e9a --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_25.txt @@ -0,0 +1,17 @@ + + ecdcdcccce + o coceedexcxxo + oxoooocoxcedexxxe + xccx o dx cexoceo + oeeeoocedoexc xooeoc + eoxxxeccoexd oxoxooxo + xoxcx xeeoeeoxeoecxdx + xxxoxoc xedeoxeexdxxe + ecexcxeeddddcxxeexccxe + oocxoxoxexxdcexecdoex + excoexecccccccoxexoxe + xecxdcdeoocdeooooxo + eeexeeecdooeoexxo + eodeeecdxcc cxc + xoccecoecxc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_26.txt b/codex-rs/tui/frames/codex/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..1592c09e8cfa63602ee2721eed734df6ccad5287 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_26.txt @@ -0,0 +1,17 @@ + + edccccco + ocxdoexcdxo + occcxdexecceo + dccoxxxexxecoe + xeoexoxcceodxed + e cxodocceeceeo + x ccdxxoxxddcc + oo exxxeedxxoxx + x oecdcxcddoexx + oexooxeeoceecx + xecoxcceooecexx + eexxoe oocxxe + c cxe eeoxoo + xcceecceccd + eodecxeec + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_27.txt b/codex-rs/tui/frames/codex/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..5279157c040ea91230fd6d9ea91f2caf0aac47e6 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_27.txt @@ -0,0 +1,17 @@ + + dcccco + xddoxoe + dce cxocx + xxxexxdxx + x exeocd + xeoecexxxe + d cxxecxx + x exxxdcxx + xo o xcxxxx + cd ocexecxx + xo eecccoc + xxccxxeox + xddcdooxe + eeexedoo + cec eeo + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_28.txt b/codex-rs/tui/frames/codex/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..ea695865f4a8110bb68b824be3f4e131b4ebc980 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_28.txt @@ -0,0 +1,17 @@ + + occd + xcexe + d dxe + xoecx + x xx + x ocx + exx ex + xoccx + oe ex + xxodxx + x ex + xdcdx + xdcxx + ceeox + x ocx + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_29.txt b/codex-rs/tui/frames/codex/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..328d426a41597cfbc8dcd4a9cdbffdde8912f4a9 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_29.txt @@ -0,0 +1,17 @@ + + ccccco + oxco ce + eoxx ccx + xxxxeeoo + e xcx x + xoxxx ee + xeexxx e + xxdxx + xxxcx e + exdxx e + cxxoxe d + xoxxx ex + xxxxexex + xdxcocxc + xxc oo + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_3.txt b/codex-rs/tui/frames/codex/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..3e9206577af57c7969cd08114d8922dfc06d2524 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_3.txt @@ -0,0 +1,17 @@ + + eoddccddddoe + ecooexxcxcddxdeexcce + odocdxccce ecx cccoexo + ocoexdoce edc xxe + cocxoeoxxcee eeexxe + oxeeo ooxedee x eex + dc x ccexecxo ocoxo + ooxox ooxcoex xexdx + occx dxccxxcoxdcceeeccexecdx + oedeo oocoddx xcxeeo doodeexexe + cex x cxxcoc cccccccccoooooo + ccx ec e oeceoo + deooceo ocdocoxc + decoecceddddoddcdeecc + ecccedxeeeexdoec + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_30.txt b/codex-rs/tui/frames/codex/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..b9da98c5c37845072063f4ae96b24bc52f078b38 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_30.txt @@ -0,0 +1,17 @@ + + edcccco + eodxxeccde + ccexoeexcoe + xxexoxeexe eo + dcxxeexoxo x + xcxec cxxxxox + eoxxxee eoex de + cx ccdxoxcxo e + cxecexdxeoxo e + cxxexeexx co e + exxdeecxxxcxx + xcoooocexxc x + exexxocxxoxo + oeocdeoxooc + eooxeeedc + eeee \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_31.txt b/codex-rs/tui/frames/codex/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..baef07474cb81424325517d37bde5dc67fbdd6bc --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_31.txt @@ -0,0 +1,17 @@ + + eodccccdo + eccdcoeccecco + oxeooodeeocxece + oxoceecdeoeexexxe + exxxxcoceeocexoee + dxeexexccedcoooxocx + oxx xecxeododcxcxox + eeo xxcxe xeccxxeox + xeoexcexxxocxxxe x + cxxxooxxeeeeexexx c + eceocxo occceoxcxe + xxxeeeo edc x + dxcde o o xceoe + dxexoexeoxcoxe + ccdxeccoodc + eeee \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_32.txt b/codex-rs/tui/frames/codex/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..c0997d9a140ce92f99796162ffbe57b3eb7251d2 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_32.txt @@ -0,0 +1,17 @@ + + occccddxoe + dcxccxexxccxxo + oecdeocedoecxcecco + cooxeoedo o oeexco o + ooxoxceccxd ceoxeoeceo + eoeeoecxedxxce xco c + cxcdoecexxooxodeoeooxce + xxxoe cexxcocxdoecexcce + exxoe cexceexcccxxxdxcde + ccceexceceeeeeexxcxdxoe + oecxxo xccccccedooooo + eoxeee oexocx + cccxxxce eoexo o + eoecxcxddceecceo + xddeeococecc + eee \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_33.txt b/codex-rs/tui/frames/codex/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..cd8691c1502170948ac99daf3a89c34200e5baaf --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_33.txt @@ -0,0 +1,17 @@ + + eocdcdcdxoe + eccxxecdxdxoxcxco + eoexcccodce ccoxxcco + oeoxoexoe cxxeec + eoxoexxoeee xceee + ooo eeeeeeo oeoee + xxc eocxexe xcxx + cxoo occeodo ecxc + xxx x oe ocooodddcxxcoexex + eoxccodooexxeeeeexxceeexxo + edeo xoxo o ccccccc xxooee + ececoco oododo + ceccocdo ecxoocec + exccecxodcecdoecxc + cddcoeeeeccdoc + eeee \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_34.txt b/codex-rs/tui/frames/codex/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..ef8eabf7dc0b175a6c774f2b854ac886ad533dea --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_34.txt @@ -0,0 +1,17 @@ + + eodccccdxoe + ecccoedxcxccdccdode + eoooxccdxce eeeeoccco + ooexceooe cxocee + ooxeoox xc o eecee + ooexeo eecxece eoexe + xcexe ceecxee xdxe + exdcd xcexocx o ee + ocooc oeooceddccccccxeec xee + xxeooooecoocxxxexoeeeooexxexe + oeeo xexce ccceeeee oooexc + ooeddee odoxoc + ecexcedo ecdceooe + oeoxcxcodocdcdceccdc + cxeddeeeeeddcde + eee \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_35.txt b/codex-rs/tui/frames/codex/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..1c53d2373f21648062b4f106136fbdec3f44f0b5 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_35.txt @@ -0,0 +1,17 @@ + + eocddcccdcoe + ocdcoxccccxcxdcxcde + eooxdccxecce eccdcocxco + ooocoodoe cceexe + ooxxooe ceco xecxo + dodoce cxecooce xeex + e oo ee cceo xeee + xxxd oeedoc o co + o oe oxxodcoxddededcxx xxdc + xoedc oodcccoxd eoeeeeocxoc xc + oeeocoeexoee eceeeeceecooeox + coeeox eoc oe + edeedodo odoeccc + ceecxcxodxxcxdocceodc + cexddeeoeecccce + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_36.txt b/codex-rs/tui/frames/codex/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..4928a2a9d071872912ab75ac17efadb0d6ffa796 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_36.txt @@ -0,0 +1,17 @@ + + eecdccccdcoe + edccecodocecdcccccce + oeceoccoccee eccxccxocxo + exccceeee ccxecx + ooedecxeeeoo eeoc + o ooe ce cxoo ceec + x d e e cee xexo + xxex ee eoo ex x + xccx oo occcocceccccce xexx + ecxe oxeeoxo exeeeeeeexx eoce + c cxe cecoe ccceeeeec xoco + cocedo ooxco + eoxxcxo occcooe + coecccdedxecxdcocodcc + eccoxeooeooccccc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_4.txt b/codex-rs/tui/frames/codex/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..a5ae50eeae4bb25dfe12d01083b8b837c5cbb0e1 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_4.txt @@ -0,0 +1,17 @@ + + eoddcddcdcoe + eocccxo xedocdxcocoe + ocdxxxccce eexeecxoeee + ooxoxcoco cxccece + oxoexdxedexo ecexce + oxxex cexxoce c edxo + cexde ccceccxo o cdx + xoe oxocexx xxx + ex xe dxoceoocxccceeeeoo xxc + xeo x oooooexedexooeodoedxoocx + exdceoeeoeo ccccccccceoxxxe + oxecxee oedoccc + eeode oe eoeocdxo + xxxdecceddddocdccexce + ceexdxeeeeedoce + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_5.txt b/codex-rs/tui/frames/codex/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..47abf7a0af69735a45c27a4bbcb3948f47a05702 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_5.txt @@ -0,0 +1,17 @@ + + eodddcdddcoe + ecccocoxxcxdcdexcco + ceoecxcce cedxce oco + oxoxoxodo oexxxxe + oeocoeecxeeo e exexe + xxxxo eexedoxe coxx + xox c eeexecxo ccxcxo + xoxec oedxoex cxex + xoxec oexcoxcdexcxeeecoecxox + eexeoooccc xc ooedeodoooxocxe + eoxxoxoeeoce ccccccccoxdxeo + eecoee e oeocoe + eocexdce edcoccdc + excxoccedxdeocdcexdc + eocecceeeoeocce + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_6.txt b/codex-rs/tui/frames/codex/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..ba04c52772f34f539175660b619e7bcda8ee8f4a --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_6.txt @@ -0,0 +1,17 @@ + + eodddcddccee + oedocxdccxccdeocce + oooxxxcc ecxodcccoee + ooxcxcedo ecxxxxo + cdxxxceecce oeexeo + dooxoeecdxeexo odeox + e cxx eexoee ecxex + x xxx exdxoxx xcexx + eoxox ocxxcdocexcxeecceoeox + xxeooeeedc xodcoxxodddexoxe + o x oxxxdoc cccccccceeeox + o d ooe odeooxe + oeeecco eceeococ + ecxcocc dxeeoccc ddc + eodccoeeeeeocc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_7.txt b/codex-rs/tui/frames/codex/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..f7dd0de9b609858c3c4d1bed2d57351ee513f4f3 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_7.txt @@ -0,0 +1,17 @@ + + eoxdccddcoe + ocecexcccdded eco + c oxxxce eexe coeco + eexxxxdc execxoo + ecxcxxexoeo o cxxe + x dxxc ecedxe ooxxx + eecx eexexex ccexx + ecoxx dcoxcxe c xxx + ocxc oceoxeoxxddexxddcexx + xocxxddxxocxoexxeeododexex + e deocxceo cccccccceoxx + ce oeoee ocoodoc + cdoceeee oexococc + eecexcedxeeeccxcco + cxccxdxeeoedcc + \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_8.txt b/codex-rs/tui/frames/codex/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..e3f93702f726960f3f734ac2d69602d4be6a702d --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_8.txt @@ -0,0 +1,17 @@ + + eecedccdcoe + occoxxxcdd x cc + exdoxooc ocxoxceceo + ececoxocx c dexxe + c oxooexoe ocexxo + xcoxeeeeexec ecxxx + xcxxx cedexex xexoxo + eoxxo xceoxoco oeoxx + e exxee ocdxxococooexxx + ecodexcoxoxxdxdeexxdc + ooeexexxxocececceccoex + cocxexee oooooc + ceeceeee eoeexcoc + odcxoc ddccdodoxe + xxeccexeocode + ee \ No newline at end of file diff --git a/codex-rs/tui/frames/codex/frame_9.txt b/codex-rs/tui/frames/codex/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..210e417d435cdd6a077f8ff4e1977f0868978b53 --- /dev/null +++ b/codex-rs/tui/frames/codex/frame_9.txt @@ -0,0 +1,17 @@ + + odecoccdo + oceoxxccd eoee + o oxxoceedo eexo + c xxodde eeoxeexco + occdeccxco coccdcxx + x xxxcexedee xcxcxxx + e xcexccxeeocooo exx + x xoeoexxxeodeex xx + coxc oxcxxxxcxxoxxe + xexxxeoxexexxxxeexxx + c eeoeexocccccxxcoex + exxxeoe oo eoxe + ecexee odedxoc + eoceexcocdddcxe + coe ceexdcoc + \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_1.txt b/codex-rs/tui/frames/default/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..64a140d2b9cff8b0672ab623dd3edece5911fb8a --- /dev/null +++ b/codex-rs/tui/frames/default/frame_1.txt @@ -0,0 +1,17 @@ +                                       +             _._:=++==+,_              +         _=,/*\+/+\=||=_ _"+_          +       ,|*|+**"^`   `"*`"~=~||+        +      ;*_\*',,_            /*|;|,      +     \^;/'^|\`\\            ".|\\,     +    ~* +`  |*/;||,           '.\||,    +   +^"-*    '\|*/"|_          ! |/|    +   ||_|`     ,//|;|*            "`|    +   |=~'`    ;||^\|".~++++++_+, =" |    +    _~;*  _;+` /* |"|___.:,,,|/,/,|    +    \^_"^ ^\,./`   `^*''* ^*"/,;_/     +     *^, ", `              ,'/*_|      +       ^\,`\+_          _=_+|_+"       +         ^*,\_!*+:;=;;.=*+_,|*         +           `*"*|~~___,_;+*"            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_10.txt b/codex-rs/tui/frames/default/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..9d45417346bf4b66917e4a03747b9be71bf0497c --- /dev/null +++ b/codex-rs/tui/frames/default/frame_10.txt @@ -0,0 +1,17 @@ +                                       +              _+***\++_                +             *'`+*+\~/_*,              +            ^_,||/~~-~+\,,             +           |__/\|;_.\,''\\,            +           / ;||"|^  /_/|/            +          |` '|*~//\   `_"|            +          \  ~*"*||~|*   |/,           +          "  ||\+/+||-_ .\||           +          "  ~\ \\|;~~+\+;||           +          |  ,|\,|_/_*___|*`           +           , "|||||""!\,"\|`           +           \`',\,*"  "",//            +            |' |||~*,:,/|/`            +             ;`**/|+;_!//'             +              *, _*\_,;*               +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_11.txt b/codex-rs/tui/frames/default/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..769e5ae76d774867cbd2feee6a3cce0b24faced4 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_11.txt @@ -0,0 +1,17 @@ +                                       +               ,****++_                +              /" ;|||\\,               +             /"__||;\*/\,              +             |__||=;,_=//              +            _".;\|+\';_||,             +            |+`||+_;;|_/||             +            ** ,||||||_|=\             +            |  ||/||\/ |"|             +            /  '|/||*/+|_|             +            ** _|/=,"/|_|^             +            '`- ||||=/|\\|             +             \_-/|_*/**;|`             +             !_ *|\\^_|;"              +              \+!*||,_/*`              +               \_ '*+_+`               +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_12.txt b/codex-rs/tui/frames/default/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..50cfd73302d871b33be702f7da4d73809531627a --- /dev/null +++ b/codex-rs/tui/frames/default/frame_12.txt @@ -0,0 +1,17 @@ +                                       +                +***+.                 +               ,=`_/|,\                +               "  |/\+,                +               /+~||=="|               +              | '~|||./|               +              |'..*^"_|"               +              |   ~/\||\               +              |   /+\||"               +              *, ~/||+|~               +              |   /|*;*_               +              |.  |"=**/               +               ,  *|!_,|               +               / **|,*\|               +               '^/",|\|`               +                \ '~\./                +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_13.txt b/codex-rs/tui/frames/default/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..04ed71335c1962bf4353c87e8ee4b518c221afb8 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_13.txt @@ -0,0 +1,17 @@ +                                       +                 /***,                 +                 |__||                 +                 |`_|"                 +                 |**|_                 +                 *  ||                 +                 ":-||                 +                 ,  ||                 +                 +  "|                 +                /+  _~.                +                |"  +=|                +                '`.. ~`                +                 |___|                 +                 |+,|_                 +                 *__|=                 +                 , ."=                 +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_14.txt b/codex-rs/tui/frames/default/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..66e91f7187b148e927e8ac6b0683abe10f7ba029 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_14.txt @@ -0,0 +1,17 @@ +                                       +                 +***;                 +                 ,/__.\                +                |_||. |                +                ||/|"^~,               +                |||\   |               +                ~*||  '|               +                |||| . *               +                ||\|`  \               +                |||~   "               +                "^//  ;/               +                \|"",.,|               +                |*~|___|               +                /!"|===`               +                |\/*__/                +                 _* '=/                +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_15.txt b/codex-rs/tui/frames/default/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..9d8132e3c4105b4af4b97e4d70dc84744d140a81 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_15.txt @@ -0,0 +1,17 @@ +                                       +                 ++***~_               +                `,=||^:*,              +               //|*=\|"*|              +               |/` //,.__|             +              ||="=\||/"^|             +              \||-||//|  "             +              ||   ||||~~,|            +              ||/~|+||| '-|            +              ||+,,*|||_.:|            +              |_|;/|\~*. .|            +              |/||||_| ` ;             +              |**.^~|\-  =             +              '|\, ///` ;`             +               |^||\\.+\/              +                \^*^___/               +                   ``                  \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_16.txt b/codex-rs/tui/frames/default/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..7217fe58b8e3e0e9ec1036ae38c5dd11222a1810 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_16.txt @@ -0,0 +1,17 @@ +                                       +                _=+"**+~_              +               /^||*||\|=\             +              |//"\/=\|| '\            +             /// ;' \|\||**|           +             ||;_ =||*/|`\           +            |||*|  /|= !| ~.|          +            \\|  ,||||/*", |          +            |/; |`||/|||"; `|          +            \\|~|+~/^||"*+  /          +            *"__,==\*|._| ,_|          +            |||+""/*\|;";.~|`          +             ||* |   `//,  /           +             \|*  |  /,/_,|            +              \|~"_*~//+_|             +               ':._=:__;*              +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_17.txt b/codex-rs/tui/frames/default/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..0d873df75182a924e93d9f6857970fda364d299e --- /dev/null +++ b/codex-rs/tui/frames/default/frame_17.txt @@ -0,0 +1,17 @@ +                                       +                ,=+++;;~,_             +             _;**|~~*=*|,"^,           +            ,*\/_==`+,"|||_"\          +           /|/_/|"   |;\~||=\         +           |/_ ~     "/\=\//  ,        +          `=*,/`   ,:/| /,=/|./        +          *!;/|   ,//|_ *"||/=|        +          -"=|!   !//||/ ,||=;*        +          ,/*/\==+~\_|\^:\||| |        +           |"_;__|/*\/||\!\+'+\        +          \\\/"""****\_|*//\ \'        +           \||_*       `/||` ;         +            _\\!*\_   ,',/^_/          +             , ~*+=\+`_;*:|'           +              `+;/_,+~*_+*             +                   `                   \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_18.txt b/codex-rs/tui/frames/default/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..a474a4f3d03281b70b376a6f6789acd827ac69b9 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_18.txt @@ -0,0 +1,17 @@ +                                       +               _==+==+;~,_             +            _+"_;,++~__,"+;;_          +          _/:|*"*=" "._"+//\ *         +         ,||*.,^" _/=~\;\\\,       +        _\// /|   _\/~/|_\;\\_       +        /\| ,, _/,*,|'-/^/`/~!      +        ||\:/     +/*/|"_/"*|=|=,      +        "\-~|     ^\"||;^   |;|"       +       \"" ,\==;=;+~|,|*/\, |*|`       +        _\\|*\* ~|/__\_~||_;~`\ ,      +        |/|\`""*****` \__/|/*/`-`      +         \\!,\,         ``*"/*_'       +          \^*+^^.      _*^_/\,`        +           '|.**++===^'*_/_,*          +             "~|_/__,.+";+"            +                    `                  \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_19.txt b/codex-rs/tui/frames/default/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..e83b78bd3ba7b6460c759ae15b3a4c4c350d5594 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_19.txt @@ -0,0 +1,17 @@ +                                       +              __==+~+==~._             +           _+|||\/===_*_,|*,,          +         ,*;;/"|+*`    `*:,`*;\        +        /;|*,^`         _==+,^|*,      +       ||/`/`         ,|^"/"|\ |\,     +      |\/*'         _|*~/!./ '.*|      +      ^=|~'        /*^/|+,`   /:/+|    +      ||||         |;"\|",    | |||    +      |*|\,=;;===~~~|+*;*|;   \ "||    +      ^;/,|=*/^*+\,`, ^_ :\_\,/.|_     +      '\"/+|"""""**"   ^\_/|// //'     +       \\^*_\             `//_//'      +        '!_\~~*,        ,;=./|;`       +          '".|*/~+__=;/*" ;*~'         +             "~._:-'_,.^*-^            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_2.txt b/codex-rs/tui/frames/default/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..ac205dd4a51f2b07cedac4de9435acbc35d0fc1b --- /dev/null +++ b/codex-rs/tui/frames/default/frame_2.txt @@ -0,0 +1,17 @@ +                                       +             _._:=+===+,_              +         _+,/*;+||~=~|=_'|*+_          +       ,|*|\**~*``  `"*=*/||;|,        +     _|/_/*',,_           -,\|/|,      +     ,^"/*^|\_\\_           ^,|\|,     +    '/+"`  |*\+/\+           \\|*|     +   ,|'*|    ^/|;|_|,          /"|"|    +   |" \`     ,|*||;*          |'/.|    +   *""=|    ;|^^_|".~++++++++,|_|~*    +    _='|  /||' /* |=\____..__|+/!|!    +    \\\ * *\..|'   `"*******"|,*||     +     '\_./, `              ,+;/,*      +       .\__|+,          ,=_+!_|"       +        `~^;__"*+:;=;;.=*`_||*         +           `*+*+~~____~;/*"            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_20.txt b/codex-rs/tui/frames/default/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..bff8cc065f94a0b11078ea3727acebf71faa61e1 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_20.txt @@ -0,0 +1,17 @@ +                                       +              __+=~~=+=,__             +          ,;=";,_,===||_^*\+,          +        ,,"_+*"~*"    `"^"\+*|+_       +      _|"_*|*           _,+|\/*\\      +     _| ,|*           _/!_||^*\||\     +     /`,|'           +*';|"/  '\~|\    +    ;|;+|          ,* .+*^     ,\|/    +    |_^/`          "";:|",     |~"|    +    |/||;,=;======_,';^^\\*    / |\    +    '|^|_" /``____|\  *\\"|=\ ,/ ||    +     \,^\'"""""""*"     \,=||,| /\     +      * ^*/,               _/*.|/      +        \_^*,~_          ,;*`;*'       +          ^,-."~+^;;,:"~"`,/*'         +            `*+~_!=____|*""            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_21.txt b/codex-rs/tui/frames/default/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..b23aadbc7c701bc8b0048c98e41ffcf1a4a75a0c --- /dev/null +++ b/codex-rs/tui/frames/default/frame_21.txt @@ -0,0 +1,17 @@ +                                       +             __,=+==+=+,__             +          ,+*``__+~=~+;_`-*+_          +        ;*^_+*^"`     `^~*+_`*,        +      ,*|;"'             _,;*,;*,      +     /,/*`             ,|/_\ \\_^,     +    /"/|             ,**_//    \\^,    +    |'|`           _!/`,/'     \;\"    +     `\            \; "|,       | |    +    | |  ,+;;;;;+++  ^|,"|,    ;/ |    +    | ~\ |_      _,|   ^"`*|,  /"./    +     ; ". ``"""  '`     '*_,; /` ,     +     '+ :;_                 _*" /      +       *_  ^-_          _.;*' ,`       +         *=_ *"*+:~~;:=*""  -`         +            *++;+____,_;+*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_22.txt b/codex-rs/tui/frames/default/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..ccc8480d8b14b344fe8345fe5c3d4a755fa2097b --- /dev/null +++ b/codex-rs/tui/frames/default/frame_22.txt @@ -0,0 +1,17 @@ +                                       +             _,+=+==+=+,_              +         _+/*|._/|/\||;|+/*+_          +       ,;*;~\**`    `"*\**+__+,        +      ;*~**"            ,,|||_*\       +     \|~/'            ,_/|=|./;'|      +    \|*/`           ,~/|;^/` \|\=|     +   |+*\/           ~+|\*/'    ~|/|     +   ||=|`           |\|~^\     |^|"|    +   ||,", +~==;;;=++_\*|_!\,   _|;"!    +   ^|= *_\\,!/,"~//|  \*;_"|,,!/\=     +    \\\_| """""**"`    `:*+_///",`     +     \*\_\,               _*,"|,'      +      '"+; ++_         _.*,"*_/        +        ':*+,"*~;=+;;/=*""',*          +           "~"~_;___-=_.=*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_23.txt b/codex-rs/tui/frames/default/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..406ced01b086727b712f0e389459168ca32ba652 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_23.txt @@ -0,0 +1,17 @@ +                                       +            _,+_=+*++=+__              +         _=|+|\_,_==,|_|*|+_           +       ,"+|',;*`    "~:+|\;||,         +      /;|*;/`          _;;\||;\        +     //|`/'          ,/,,'*/*\|\       +    ,\|"/`         _***''/*'|~\|,      +    `||"|         /:/. _|`  "!|'*      +    ~/| |         |"|,_\,   | */|      +    ^"\ |+~;=====;=|_*|_"\_ | |~|      +    |\\_|"="       /`\ \|_\,~ \_|      +     /'| | `"""""""   '`/;=|_/^/`      +      ,||_|.             ,/|\^/`       +       \ |,'/__       _.";*/+/         +         \=\+;*+\~==_++"-_+*`          +           "~!*=\~__+__**`             +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_24.txt b/codex-rs/tui/frames/default/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..73f563939029242492c3b5b829aec8f617bd5cdb --- /dev/null +++ b/codex-rs/tui/frames/default/frame_24.txt @@ -0,0 +1,17 @@ +                                       +             _~_;++*==,_               +          ,"_/"|__~==+,_'+             +        ,"';\/+"~ .`:*~**, \           +       ,'//,/|=,\`~/`!_*\|\_'          +      , //\/,    `""/\_|``\|,'         +      ~,\+\`       *,,/!.|;!/"\        +     |  |~!      ,/_,/"/^\|\^|^        +     | \||       |,=/\_|~_/.`||        +     |. |;;;:++~~~++_*,_| |  ||        +      _ / !*|/,,;;,,|.^\+*| |*|        +      \ '\|\*""""""` \,.*\|=/,`        +       \ \,*\           |!"/;/         +        \.*\`|.      _="_/:_*          +          .-*,\^"~~:==;|^_/`           +            *:_*+;\__==+*              +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_25.txt b/codex-rs/tui/frames/default/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..6fb0cbc16cf78cc2c9958fbcb6cde154e83c2553 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_25.txt @@ -0,0 +1,17 @@ +                                       +             _+=*;++++_                +           ,!*,*`_;\|*||,              +          /|//,,".|+\=\|||_            +         |+*| /! =| "\|,*\/            +        ,__\,/'^;/_|" |//\/*           +        \,~|~_*+.^|: /|,|//|,          +        |/|*| |__/\_/|\/_"|=|          +        |||/|."!~_=\/|\_~=||\          +       ^+\|"|__====+~|\\|+*|\          +        /-"|/|,|_||;*_|\*=/\|          +        \~*/\|`"""""'+/|\|/|`          +         |_"|;+;\-,*:_/,//|/           +          ^`^|_\_*;/,^/_||/            +           \.;\\_*=|**!*|*             +             ~,"*\+,_+|*               +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_26.txt b/codex-rs/tui/frames/default/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..8bd6052839dbbca6185a747a9b64fa4e7aa35d89 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_26.txt @@ -0,0 +1,17 @@ +                                       +              _;***"+,                 +             /*|;/\|+;|,               +            /*""|;\|\""\,              +           ;*",||~_||_+/^              +           |_,\|.~"*\/;|\;             +           \ "|/:/"*_\"\\/             +          |!  *'=||/||;;"+             +          /-  \|||^^=||/||             +          |  .\*;+~+==/\||             +          ! ._|/,|__,*\\*|             +           |`"/|*"\,/`+\||             +           \_~|/\   //"||`             +            *  *|\ ^`/|/,              +             |"*\\"*_**;               +              \/:^*~_\*                +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_27.txt b/codex-rs/tui/frames/default/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..e8630695b8d3f90fd360e6d771011ba51a97df95 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_27.txt @@ -0,0 +1,17 @@ +                                       +                ;***+,                 +               |;:/|/\                 +              ;'` *|/'|                +              |~~^||;|~                +              |  `|_-'=                +              ~_._"`|||`               +              = "||_*||!               +             |  `||~="||               +             |. .!|+||||               +             '= ."_|_*||               +              |- _^**+/'               +              ||++||_/|                +              |==+=,/|^                +               \__|\=//                +               '\" ^\/                 +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_28.txt b/codex-rs/tui/frames/default/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..3313d8b9bf76dc3adefcf4fa9af273e4b5346ab5 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_28.txt @@ -0,0 +1,17 @@ +                                       +                 /**;                  +                 |+_|`                 +                 = ;|`                 +                 |-`*|                 +                 |  ~|                 +                 | -"|                 +                ^|~ _|                 +                 |-""|                 +                /\  _|                 +                ||.:~|                 +                 |  _|                 +                 |=+=|                 +                 |=*||                 +                 *__/|                 +                 ~ .+|                 +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_29.txt b/codex-rs/tui/frames/default/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..2ae088f1b905a6e8e348acd98b5f5f31bd86b5bf --- /dev/null +++ b/codex-rs/tui/frames/default/frame_29.txt @@ -0,0 +1,17 @@ +                                       +                 +****,                +                ,|*/!*\                +                ^,|| '"|               +                ||||^\,/               +                \ ~"|  |               +                |,~|| __               +                |\\||| ^               +                ||=~|                  +                ||~*|   `              +                _|=||   `              +                *||/|^ =               +                |/~~| _|               +                ~|||`~_|               +                |=|*/"|'               +                 ~|* .,                +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_3.txt b/codex-rs/tui/frames/default/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..727e25a8e89b8f395879c50287e3dc1b166064d9 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_3.txt @@ -0,0 +1,17 @@ +                                       +             _.=;++====,_              +         _+,/\||+|"==|;_^|*+_          +       ,;/*;|*""`   `"~!**+/^|,        +      /+/\|;,+_          `=*!||\       +     '/*|/^/||*\_           \^\||_     +    /|\\/  .,|\;\\           | \\|     +    =* |    '*\|_"|,          .*/|,    +   ,-|,|     ,-|"/\|          ~_|=|    +    -"'|    ;|*+~|*-~=++___++_~^";|    +    ,\:\, ./*/;;| |*|__,!=.,;\`|\|`    +    '^| | "||+,"   "***"""**,///,/     +     '*~ \+ `              /^+^//      +       =^//*\,          ,+;/",|'       +         =^+,_**^=;=;,:=*;`_+"         +           `*+*_:~____~;/^"            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_30.txt b/codex-rs/tui/frames/default/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..99eeebce339dad86fa97019efe7812ff431589e9 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_30.txt @@ -0,0 +1,17 @@ +                                       +                 _;"**+,               +               _/;||\*'=\              +               "'^|,\\|+,\             +              ||\|/|_\|\ \,            +              =*||`\|,|,  |            +             |*|^+  *||||.|            +             \/|||\_ \/\| =`           +             "| '+=~,|"|-  `           +             "|_"\~=~\/|,  `           +             "||\|__~|!+,  `           +             !\||;\_*~||+~|            +              |*//,/*\||" |            +              \|\||/*~|,~/             +               ,^,+=^/|,/'             +                \-.|^__;'              +                  ````                 \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_31.txt b/codex-rs/tui/frames/default/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..8d9adf28b24af21718de6e8b8ddb8a5c5614bf90 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_31.txt @@ -0,0 +1,17 @@ +                                       +                _.:*+*+=,              +              _+*;+,_"+\'*,            +             ,|\//,=_`."|_*\           +            ,~/+__*;_,\\|\~|\          +            ^||||+-*\_,"\|/__          +           ;|^`~_|'"\;*,./|,"|         +           /|| |^*|\.=/;*|*|/|         +           \\, |~"|\ |^""|~\.|         +           |\,_|'^~|~/+~~|_  |         +           "||~//||___\_|\|| *         +           ^*_/+|, /***`/|'~_!         +            |||\\\,     _=* |          +             ;|*=_!,  . |*`/`          +              :|\|/_|`,|",|`           +               '"=~_+*/.;*             +                   ````                \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_32.txt b/codex-rs/tui/frames/default/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..4175a7a66ef7f8724722d39216a60581813c845c --- /dev/null +++ b/codex-rs/tui/frames/default/frame_32.txt @@ -0,0 +1,17 @@ +                                       +                ,++++;;~,_             +              ;*~**~\||**|~,           +            /^*=^,+^:-`*|*\'*,         +           '//|_,`;- - ,_\|+,!,        +          //|,|*\'*|; '`,~\/\*\,       +          \/\\,\*|`:||+_   |+/ *       +         *|";,`'\||,,|,=`/_//|'_       +         |||,_  "_||"/'|;-_"\|""`      +         \||-_ '_|"__|+++~~~=|"=`      +         '""_`|*\'\_____||*|;~-_       +           ,\*||/ |*""***^;///./       +           \,|^\\        ,\|/'~        +           '**||~+_    _/_|/ ,         +             \-\"~+|;=*`_+"_/          +               ~;=__,+/*_""            +                   ```                 \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_33.txt b/codex-rs/tui/frames/default/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..dbd9568018a1864bbb90881eea51e75b1bf53e59 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_33.txt @@ -0,0 +1,17 @@ +                                       +               _,+=+=+=~._             +            _+*||\*=~:|-|*|*.          +          _/\|+*+,="`  "*/||+",        +         ,\/|/_|,_        *||\_*       +        _.|/`||/\^\         |+\\^      +        //,   \_\\`\,        /\/_\     +        ||+ !  \,*|_|\       |*||      +        *|,,   ,''\/=,       \"|*      +        ||| | ,` /*,,,;==+~~+/_|\~     +        ^/|'"/:,/`~|_____||*^^^~|.     +        \=\, |/|/ / """"*** ||,/^`     +         \+\*,",           //;/=/      +          *\"*,*;,      _+|,/*\'       +            \~"*\+|,;+_";,\*~*         +              "==+,___^+*;-"           +                   ````                \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_34.txt b/codex-rs/tui/frames/default/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..7fc67a92dbcd0c6dce8868892b3369349124ac22 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_34.txt @@ -0,0 +1,17 @@ +                                       +               _,=++++=~,_             +           _+*+/\;|"~+*=**;,=_         +         _//,|*":~*`   `^\\,"**,       +        ,/_|*_/,_          *|,*\\      +       //|\-/|!|"!,          \\*\^     +      ,/\|`/ \\"|\*\          \,\|\    +      |*^~_   '\_*|_^          |;~_    +      \|=":    |*_|/"|         / _\    +      ,'/."   /\//"_==++++++~__" ~^`   +      ||\,/,,^"//'~||_~.___,/_||`|\    +       /_\, |\|*^!  "**````^ ,/,\|'    +        /,\;=\_             /;.|/'     +         \+`|+\;,        _+="_/,`      +           -\/~"|+,;,+=*=*`+*:'        +             "~\;=_____;=*=^           +                   ```                 \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_35.txt b/codex-rs/tui/frames/default/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..570f34f0de5d20a55e5f95834a215b1df97838e8 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_35.txt @@ -0,0 +1,17 @@ +                                       +              _,+;=+++=+,_             +           ,*=*.~**+"|*~:"~*=_         +        _//|;+*|^*"`  `"*=*."|*,       +       ,//+/,;,_            *+_\|_     +      //~|//\ "\*,            |\*|,    +     ;/;/+` '|_"..*_           |\\|    +     \ !./    \\ '*_.           |^\\   +     ~|~:      /\\;/'           / "/   +     / ,_    ,||/;"/|==_;_=+~~  |~=*   +     |,^;'  //;"*+/|; \,____/"~/' |'   +      /`\,'/\_|/^`  `"^^^^*^^*//_,|    +       ".^\/~               _/* ,^     +        \;`^;,:,          ,;/_**'      +          "^_"~*~-:~~+~;/*"_/;"        +             "^~;=__/__++*"^           +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_36.txt b/codex-rs/tui/frames/default/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..74d83c8e702570dd41692425472c7671794ab809 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_36.txt @@ -0,0 +1,17 @@ +                                       +              __+=++++=+,_             +          _=""\+/;/+\+;++"**+_         +        ,\'\,+*-*"`` `"*~*+|,*|,       +      _|"*+____            '*~\"|      +     ,/_;\'|\`\,.             ^\.*     +     / ,/`  *_ "|/,            "\^*    +    | ;!`     !\ "\\            |^|,   +    ||\~      _\ _//!           \| |   +    |'"|     // ,*"',++_+++++_  |\~|   +     _*|\  ,|__/~/ !`~_______|| \/'`   +     ' *|\ +_+/^     "**^^^^^" |,"/    +      ',"\;.                 ,/|"/     +        \/||+~,           ,++"/,`      +          *,_"**=^;~_+~;"-",;+'        +            `*+/~_,,_,,++**"           +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_4.txt b/codex-rs/tui/frames/default/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..06dbce99c076afd496a600b5f036136f1390c4ca --- /dev/null +++ b/codex-rs/tui/frames/default/frame_4.txt @@ -0,0 +1,17 @@ +                                       +             _.=;+==+=+,_              +         _-"+*|/!|\=/*;|"/*,_          +       ,*=|||+*"`   `^~\^*|/\\_        +      //|,|".+,          "|**\*\       +     /|/_|=|\;^|,          \"\|*\      +    /||^|  '_||/*\          ' \=|,     +    "\|;`   '**\+"|,         , ";|     +     |.\     ,|/*^||           |||     +    _|!|_   ;|/"^//+~+++____,, ||*     +    |\/!| ,///,_|`=\|,._,:/^;|//"|     +     `|;'\,\\,\/   "*******'^-|||`     +      ,|\"|_`             ,^;/**'      +       \\,:^!,_        _.^,*;|/        +         ~||=_**\;;=;,+=*+\|*`         +            *\\~:~_____;-*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_5.txt b/codex-rs/tui/frames/default/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..6b1ce12447990449d715f2b42ca106785d8b3fa3 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_5.txt @@ -0,0 +1,17 @@ +                                       +             _.=;;+===+,_              +         _+"+/*/||+~=+;_|"+,           +        *_/\+|*"`   "^=|*\!,*,         +      ,|/|/|.=,          -^||||_       +     ,\/*/^_+|_\,         ` \|\|_      +     ~|||/ \_|\;/|_          +/||      +    |/|!+   `\_|\"|,        ''~+|,     +    |/|_"    ,_=|/_|          +|_|     +    |,|^*   /_|",|*=_~+~___+,_"|.|     +     _\|\,,/+*" |"!//\=^,=.,/|/*|`     +     \,||,~,\\/+`  "**""""",~;|\/      +      \\+/\\ `            /_/*,^       +       ^/*\|=+_        _=+,*';*        +         ^|*|,*+\;~=_,+=*^~;*          +            ^-*_*"___-_,+*`            +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_6.txt b/codex-rs/tui/frames/default/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..7724f483dc6830161b8a4ea07cc9dcc54b7892fa --- /dev/null +++ b/codex-rs/tui/frames/default/frame_6.txt @@ -0,0 +1,17 @@ +                                       +             _.=;;+==++__              +          ,^;/*|=*+|++;_,*+_           +        ,,,|||*"   `"~.=*+*/\_         +       /,|*|*_=,        \+||||,        +      '=|||*\\+*\         .\\|\,       +     ;-/|/`^+;|\_|,        .=\/|       +     _ +~|   !^\|/^\        ^+|_|      +     ~ |~|   _|=~/||        ~+\||      +     _.|-|  /'||*;/+_~+~__++_.\/|      +     ||\,/_^_;+ |/=*,||,;==\|,|\!      +      / | /~||;/"  "*"""'*"`\\/|       +       , ; /,`           ,:_//|`       +        .`\_**,       _+^_/*,+         +         `"~*,"+!;~__,+**!:;'          +            ^-;*+,___`_,+*             +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_7.txt b/codex-rs/tui/frames/default/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..0d0f43072c61f2c0a5314b26b40300fa65547fa7 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_7.txt @@ -0,0 +1,17 @@ +                                       +             _.~;*+==+,_               +          ,*`+\|+*+==\;!`*,            +         * ,|||*`  `^~`!*/_*,          +        \\|||~;+       ^~\*|/,         +       `'|*||^|/\,       . *||\        +      | ;||" `'\;|\       ,-|||        +       `\"|  ^_|\|\|       **^||       +      _"/~|   =+/|*|`      ' |||       +       /"~* ,"_/|\/~~;;_~~=;*\||       +      |,'||=:~|/'|.\||\\,=,;\|\|       +       \ =^/*|*_/  ******""_/||        +       '_ /_/_`         ,"-/;/'        +        ';,+\\\_      ,^~,*/*'         +          \_'\|*\;~___+*|*+/           +            "~*"~:~__,_;**             +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_8.txt b/codex-rs/tui/frames/default/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..2e8019c06126bf2a7575569ecee94b1f7812e36b --- /dev/null +++ b/codex-rs/tui/frames/default/frame_8.txt @@ -0,0 +1,17 @@ +                                       +             __+_;++=+,_               +           ,"*/|||*==!~ "+             +         _|=,|//*!,"~/~*\+^,           +        _*\*/|,+|     ' =^||\          +        ' /|/,\|/\      .'\||,         +       |',|\^^_\|_*      \+|||         +       |*||| '_;\|`|      ^|/|,        +       \,||/  |+\/|,*.    .`/||        +       \ \||_^ !/*=|~/+,+,,\~||        +       !  \*/=_|",|,||;|=__||='        +        //\\|\|||/'^*^*"\*"/\|         +        ',"|\|``        .,///'         +         '\_*\\\_    _/\_|+/*          +           .:'|,*!;;+*;/=,|`           +             ~~_**\|_,+/=`             +                  ``                   \ No newline at end of file diff --git a/codex-rs/tui/frames/default/frame_9.txt b/codex-rs/tui/frames/default/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..128e91500789439fb85078dec9c2a46eef3080a6 --- /dev/null +++ b/codex-rs/tui/frames/default/frame_9.txt @@ -0,0 +1,17 @@ +                                       +              .=^*/++=,                +            /*_/||*"=!_-\_             +           / ,||/*^^=/!\_~,            +          " ||/;=_ _^,|\^|+,           +         /*";\*"|*,  +:+||           +         | |||"^|\;*   '|*|||          +         ` ~*\ **|\\," / `||          +        |  ~/_ ~||_/= | !||          +        !  ",|" /|"|~~|+|~,||`         +         |_|||_,|^|_||||__|||          +         " `^/\\|/"****||"/\|          +          \~||\,`     ,/ ^/|`          +           \+\|\\    /;_;|/*           +            ^-"\_|*/+=;;*|`            +             '-_ *\\|;+/"              +                                       \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_1.txt b/codex-rs/tui/frames/dots/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..36964a48647b7cbcabf764e8e61ac5aadf989919 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_1.txt @@ -0,0 +1,17 @@ + + ○◉○◉○●●○○●●○ + ○○●◉●○●◉●○○··○○ ○ ●○ + ●·●·●●● ○· · ●· ·○···● + ◉●○○●●●●○ ◉●·◉·● + ○○◉◉●○·○·○○ ◉·○○● + ·● ●· ·●◉◉··● ●◉○··● + ●○ ◉● ●○·●◉ ·○ ·◉· + ··○·· ●◉◉·◉·● ·· + ·○·●· ◉··○○· ◉·●●●●●●○●● ○ · + ○·◉● ○◉●· ◉● · ·○○○◉◉●●●·◉●◉●· + ○○○ ○ ○○●◉◉· ·○●●●● ○● ◉●◉○◉ + ●○● ● · ●●◉●○· + ○○●·○●○ ○○○●·○● + ○●●○○ ●●◉◉○◉◉◉○●●○●·● + ·● ●···○○○●○◉●● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_10.txt b/codex-rs/tui/frames/dots/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..3c687d7f64ff62a3c24a97e9eee2dde0851ad168 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_10.txt @@ -0,0 +1,17 @@ + + ○●●●●○●●○ + ●●·●●●○·◉○●● + ○○●··◉··◉·●○●● + ·○○◉○·◉○◉○●●●○○● + ◉ ◉·· ·○ ●●◉○◉·◉ + ·· ●·●·◉◉○ ·○ · + ○ ·● ●····● ·◉● + ··○●◉●··◉○·◉○·· + ·○ ○○·◉··●○●◉·· + · ●·○●·○◉○●○○○·●· + ● ····· ○● ○·· + ○·●●○●● ○· ●◉◉ + ·● ····●●◉●◉·◉· + ◉·●●◉·●◉○ ◉◉● + ●● ○●○○●◉● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_11.txt b/codex-rs/tui/frames/dots/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..c2548db4b3c4412846f7f0b2297864fbe56b7ef6 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_11.txt @@ -0,0 +1,17 @@ + + ●●●●●●●○ + ◉ ◉···○○● + ◉ ○○··◉○●◉○● + ·○○··○◉●○○◉◉ + ○ ◉◉○·●○●◉○··● + ·●···●○◉◉·○◉·· + ●● ●······○·○○ + · ··◉··○◉ · · + ◉ ●·◉··●◉●·○· + ●● ○·◉○● ◉·○·○ + ●·◉ ····○◉·○○· + ○○◉◉·○●◉●●◉·· + ○ ●·○○○○·◉ + ○● ●··●○◉●· + ○○ ●●●○●· + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_12.txt b/codex-rs/tui/frames/dots/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..30b03392bf4dfa78f0c86c10fde3d94d89e797cd --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_12.txt @@ -0,0 +1,17 @@ + + ●●●●●◉ + ●○·○◉·●○ + ·◉○●● + ◉●···○○ · + · ●····◉◉· + ·●◉◉●○ ○· + · ·◉○··○ + · ◉●○·· + ●● ·◉··●·· + · ◉·●◉●○ + ·◉ · ○●●◉ + ● ●· ○●· + ◉ ●●·●●○· + ●○◉ ●·○·· + ○ ●·○◉◉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_13.txt b/codex-rs/tui/frames/dots/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..cb95f3763d3c5cfcfa73d7952c595a3f46173dc0 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_13.txt @@ -0,0 +1,17 @@ + + ◉●●●● + ·○○·· + ··○· + ·●●·○ + ● ·· + ◉◉·· + ● ·· + ● · + ◉● ○·◉ + · ●○· + ●·◉◉ ·· + ·○○○· + ·●●·○ + ●○○·○ + ● ◉ ○ + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_14.txt b/codex-rs/tui/frames/dots/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..3a8ed60b8ff16c9b0a14d5d4cc0aafcf3e88ce52 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_14.txt @@ -0,0 +1,17 @@ + + ●●●●◉ + ●◉○○◉○ + ·○··◉ · + ··◉· ○·● + ···○ · + ·●·· ●· + ···· ◉ ● + ··○·· ○ + ···· + ○◉◉ ◉◉ + ○· ●◉●· + ·●··○○○· + ◉ ·○○○· + ·○◉●○○◉ + ○● ●○◉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_15.txt b/codex-rs/tui/frames/dots/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..c57b4af0ee5d95d4f35f13b53cb29547ac702849 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_15.txt @@ -0,0 +1,17 @@ + + ●●●●●·○ + ·●○··○◉●● + ◉◉·●○○· ●· + ·◉· ◉◉●◉○○· + ··○ ○○··◉ ○· + ○··◉··◉◉· + ·· ······●· + ··◉··●··· ●◉· + ··●●●●···○◉◉· + ·○·◉◉·○·●◉ ◉· + ·◉····○· · ◉ + ·●●◉○··○◉ ○ + ●·○● ◉◉◉· ◉· + ·○··○○◉●○◉ + ○○●○○○○◉ + ·· \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_16.txt b/codex-rs/tui/frames/dots/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..18ae0e09ee311d03ef817e0bd57d5360c283c19f --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_16.txt @@ -0,0 +1,17 @@ + + ○○● ●●●·○ + ◉○··●··○·○○ + ·◉◉ ○◉○○·· ●○ + ◉◉◉ ◉●○○·○··●●· + ··◉○◉●●○··●◉··○ + ···●·● ·○○ · ·◉· + ○○· ◉·●····◉● ● · + ·◉◉ ····◉··· ◉ ·· + ○○···●·◉○·· ●● ◉ + ● ○○●○○○●·◉○· ●○· + ···● ◉●○·◉ ◉◉··· + ··● ·· ·◉◉● ◉ + ○·● · ◉●◉○●· + ○·· ○●·◉◉●○· + ●◉◉○○◉○○◉● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_17.txt b/codex-rs/tui/frames/dots/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..a470b4ba8df4f3b43b291f3b175e5f723039bbf9 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_17.txt @@ -0,0 +1,17 @@ + + ●○●●●◉◉·●○ + ○◉●●···●○●·● ○● + ●●○◉○○○·●● ···○ ○ + ◉·◉○◉· ○· ●·◉○···○○ + ·◉○ · · ◉○○○◉◉ ● + ·○●●◉· ●◉◉· ◉●○◉·◉◉ + ● ◉◉· ●◉◉·○ ● ··◉○· + ◉ ○· ◉◉··◉·●··○◉● + ●◉●◉○○○●·○○·○○◉○··· · + · ○◉○○·◉●○◉··○ ○●●●○ + ○○○◉ ●●●●○○·●◉◉○ ○● + ○··○● ·◉··· ◉ + ○○○ ●○○ ●●●◉○○◉ + ● ·●●○○●·○◉●◉·● + ·●◉◉○●●·●○●● + · \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_18.txt b/codex-rs/tui/frames/dots/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..c0354b39331044ecc835b73196b453d8cbecf6be --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_18.txt @@ -0,0 +1,17 @@ + + ○○○●○○●◉·●○ + ○● ○◉●●●·○○● ●◉◉○ + ○◉◉·● ●○ ● ◉○ ●◉◉○ ● + ●··●◉●○ ● ●○·○◉○·○◉○○○● + ○○◉◉ ◉· ○ ●○○◉·◉·○○◉○○○ + ◉○· ●●·◉○○○◉●●●·●◉◉○◉·◉· + ··○◉◉ ●◉●◉· ○◉ ●·○·○● + ○◉·· ○○ ··◉○ ·◉· + ○ ●○○○◉○◉●··●·●◉○● ·●·· + ○○○·●○● ··◉○○○○···○◉··○ ● + ·◉·○· ●●●●●· ○○○◉·◉●◉·◉· + ○○ ●○● ··● ◉●○● + ○○●●○○◉ ○●○○◉○●· + ●·◉●●●●○○○○●●○◉○●● + ··○◉○○●◉● ◉● + · \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_19.txt b/codex-rs/tui/frames/dots/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..c9ded56838838b15333d10c74e31ff56b20c9a58 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_19.txt @@ -0,0 +1,17 @@ + + ○○○○●·●○○·◉○ + ○●···○◉○○○○●○●·●●● + ●●◉◉◉ ·●●· ·●◉●·●◉○ + ◉◉·●●○· ○○○●●○·●● + ··◉·◉· ●·○ ◉ ·○ ·○● + ·○◉●● ○·●·◉ ◉◉ ●◉●· + ○○··● ◉●○◉·●●· ◉◉◉●· + ···· ·◉ ○· ● · ··· + ·●·○●○◉◉○○○····●●◉●·◉ ○ ·· + ○◉◉●·○●◉○●●○●·● ○○ ◉○○○●◉◉·○ + ●○ ◉●· ●● ○○○◉·◉◉ ◉◉● + ○○○●○○ ·◉◉○◉◉● + ● ○○··●● ●◉○◉◉·◉· + ● ◉·●◉·●○○○◉◉● ◉●·● + ·◉○◉◉●○●◉○●◉○ + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_2.txt b/codex-rs/tui/frames/dots/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..6e7a27fb2941fa15798e691ba4ec88acfbcbdf67 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_2.txt @@ -0,0 +1,17 @@ + + ○◉○◉○●○○○●●○ + ○●●◉●◉●···○··○○●·●●○ + ●·●·○●●·●·· · ●○●◉··◉·● + ○·◉○◉●●●●○ ◉●○·◉·● + ●○ ◉●○·○○○○○ ○●·○·● + ●◉● · ·●○●◉○● ○○·●· + ●·●●· ○◉·◉·○·● ◉ · · + · ○· ●·●··◉● ·●◉◉· + ● ○· ◉·○○○· ◉·●●●●●●●●●·○··● + ○○●· ◉··● ◉● ·○○○○○○◉◉○○·●◉ · + ○○○ ● ●○◉◉·● · ●●●●●●● ·●●·· + ●○○◉◉● · ●●◉◉●● + ◉○○○·●● ●○○● ○· + ··○◉○○ ●●◉◉○◉◉◉○●·○··● + ·●●●●··○○○○·◉◉● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_20.txt b/codex-rs/tui/frames/dots/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..d9809e733cced3d4345941545a011149363b1418 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_20.txt @@ -0,0 +1,17 @@ + + ○○●○··○●○●○○ + ●◉○ ◉●○●○○○··○○●○●● + ●● ○●● ·● · ○ ○●●·●○ + ○· ○●·● ○●●·○◉●○○ + ○· ●·● ○◉ ○··○●○··○ + ◉·●·● ●●●◉· ◉ ●○··○ + ◉·◉●· ●● ◉●●○ ●○·◉ + ·○○◉· ◉◉· ● ·· · + ·◉··◉●○◉○○○○○○○●●◉○○○○● ◉ ·○ + ●·○·○ ◉··○○○○·○ ●○○ ·○○ ●◉ ·· + ○●○○● ● ○●○··●· ◉○ + ● ○●◉● ○◉●◉·◉ + ○○○●●·○ ●◉●·◉●● + ○●◉◉ ·●○◉◉●◉ · ·●◉●● + ·●●·○ ○○○○○·● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_21.txt b/codex-rs/tui/frames/dots/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..0821f12d752a6e798016e6c97857f3778eec451a --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_21.txt @@ -0,0 +1,17 @@ + + ○○●○●○○●○●●○○ + ●●●··○○●·○·●◉○·◉●●○ + ◉●○○●●○ · ·○·●●○·●● + ●●·◉ ● ○●◉●●◉●● + ◉●◉●· ●·◉○○ ○○○○● + ◉ ◉· ●●●○◉◉ ○○○● + ·●·· ○ ◉·●◉● ○◉○ + ·○ ○◉ ·● · · + · · ●●◉◉◉◉◉●●● ○·● ·● ◉◉ · + · ·○ ·○ ○●· ○ ·●·● ◉ ◉◉ + ◉ ◉ ·· ●· ●●○●◉ ◉· ● + ●● ◉◉○ ○● ◉ + ●○ ○◉○ ○◉◉●● ●· + ●○○ ● ●●◉··◉◉○● ◉· + ●●●◉●○○○○●○◉●●· + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_22.txt b/codex-rs/tui/frames/dots/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..d67334980197871bd0ff5a2ce09541d2a30d6cb2 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_22.txt @@ -0,0 +1,17 @@ + + ○●●○●○○●○●●○ + ○●◉●·◉○◉·◉○··◉·●◉●●○ + ●◉●◉·○●●· · ●○●●●○○●● + ◉●·●● ●●···○●○ + ○··◉● ●○◉·○·◉◉◉●· + ○·●◉· ●·◉·◉○◉· ○·○○· + ·●●○◉ ·●·○●◉● ··◉· + ··○·· ·○··○○ ·○· · + ··● ● ●·○○◉◉◉○●●○○●·○ ○● ○·◉ + ○·○ ●○○○● ◉● ·◉◉· ○●◉○ ·●● ◉○○ + ○○○○· ●● · ·◉●●○◉◉◉ ●· + ○●○○○● ○●● ·●● + ● ●◉ ●●○ ○◉●● ●○◉ + ●◉●●● ●·◉○●◉◉◉○● ●●● + · ·○◉○○○◉○○◉○●· + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_23.txt b/codex-rs/tui/frames/dots/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..180ab1678424d8c117a38b97d227e09db3e2c5cc --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_23.txt @@ -0,0 +1,17 @@ + + ○●●○○●●●●○●○○ + ○○·●·○○●○○○●·○·●·●○ + ● ●·●●◉●· ·◉●·○◉··● + ◉◉·●◉◉· ○◉◉○··◉○ + ◉◉··◉● ●◉●●●●◉●○·○ + ●○· ◉· ○●●●●●◉●●··○·● + ··· · ◉◉◉◉ ○·· ·●● + ·◉· · · ·●○○● · ●◉· + ○ ○ ·●·◉○○○○○◉○·○●·○ ○○ · ··· + ·○○○· ○ ◉·○ ○·○○●· ○○· + ◉●· · · ●·◉◉○·○◉○◉· + ●··○·◉ ●◉·○○◉· + ○ ·●●◉○○ ○◉ ◉●◉●◉ + ○○○●◉●●○·○○○●● ◉○●●· + · ●○○·○○●○○●●· + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_24.txt b/codex-rs/tui/frames/dots/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..3244b1c6f925c855290aa128978fe602ae605b0d --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_24.txt @@ -0,0 +1,17 @@ + + ○·○◉●●●○○●○ + ● ○◉ ·○○·○○●●○●● + ● ●◉○◉● · ◉·◉●·●●● ○ + ●●◉◉●◉·○●○··◉· ○●○·○○● + ● ◉◉○◉● · ◉○○···○·●● + ·●○●○· ●●●◉ ◉·◉ ◉ ○ + · ·· ●◉○●◉ ◉○○·○○·○ + · ○·· ·●○◉○○··○◉◉··· + ·◉ ·◉◉◉◉●●···●●○●●○· · ·· + ○ ◉ ●·◉●●◉◉●●·◉○○●●· ·●· + ○ ●○·○● · ○●◉●○·○◉●· + ○ ○●●○ · ◉◉◉ + ○◉●○··◉ ○○ ○◉◉○● + ◉◉●●○○ ··◉○○◉·○○◉· + ●◉○●●◉○○○○○●● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_25.txt b/codex-rs/tui/frames/dots/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..c04ef18b74fa3a3912eb1a0a6933a9bcad033d0a --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_25.txt @@ -0,0 +1,17 @@ + + ○●○●◉●●●●○ + ● ●●●·○◉○·●··● + ◉·◉◉●● ◉·●○○○···○ + ·●●· ◉ ○· ○·●●○◉ + ●○○○●◉●○◉◉○· ·◉◉○◉● + ○●···○●●◉○·◉ ◉·●·◉◉·● + ·◉·●· ·○○◉○○◉·○◉○ ·○· + ···◉·◉ ·○○○◉·○○·○··○ + ○●○· ·○○○○○○●··○○·●●·○ + ◉◉ ·◉·●·○··◉●○·○●○◉○· + ○·●◉○·· ●●◉·○·◉·· + ·○ ·◉●◉○◉●●◉○◉●◉◉·◉ + ○·○·○○○●◉◉●○◉○··◉ + ○◉◉○○○●○·●● ●·● + ·● ●○●●○●·● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_26.txt b/codex-rs/tui/frames/dots/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..1ecc43beef263d217740cc2a990967ce5fb4559c --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_26.txt @@ -0,0 +1,17 @@ + + ○◉●●● ●● + ◉●·◉◉○·●◉·● + ◉● ·◉○·○ ○● + ◉● ●···○··○●◉○ + ·○●○·◉· ●○◉◉·○◉ + ○ ·◉◉◉ ●○○ ○○◉ + · ●●○··◉··◉◉ ● + ◉◉ ○···○○○··◉·· + · ◉○●◉●·●○○◉○·· + ◉○·◉●·○○●●○○●· + ·· ◉·● ○●◉·●○·· + ○○··◉○ ◉◉ ··· + ● ●·○ ○·◉·◉● + · ●○○ ●○●●◉ + ○◉◉○●·○○● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_27.txt b/codex-rs/tui/frames/dots/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..83e62da52e224a440966664ef0cd111c6be5400f --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_27.txt @@ -0,0 +1,17 @@ + + ◉●●●●● + ·◉◉◉·◉○ + ◉●· ●·◉●· + ···○··◉·· + · ··○◉●○ + ·○◉○ ····· + ○ ··○●·· + · ····○ ·· + ·◉ ◉ ·●···· + ●○ ◉ ○·○●·· + ·◉ ○○●●●◉● + ··●●··○◉· + ·○○●○●◉·○ + ○○○·○○◉◉ + ●○ ○○◉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_28.txt b/codex-rs/tui/frames/dots/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..6d460c936de04abd1156f7a6057479f2d90d855d --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_28.txt @@ -0,0 +1,17 @@ + + ◉●●◉ + ·●○·· + ○ ◉·· + ·◉·●· + · ·· + · ◉ · + ○·· ○· + ·◉ · + ◉○ ○· + ··◉◉·· + · ○· + ·○●○· + ·○●·· + ●○○◉· + · ◉●· + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_29.txt b/codex-rs/tui/frames/dots/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..d0d6b3c286dcce1b429b5548d82ab0e53783c4ab --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_29.txt @@ -0,0 +1,17 @@ + + ●●●●●● + ●·●◉ ●○ + ○●·· ● · + ····○○●◉ + ○ · · · + ·●··· ○○ + ·○○··· ○ + ··○·· + ···●· · + ○·○·· · + ●··◉·○ ○ + ·◉··· ○· + ······○· + ·○·●◉ ·● + ··● ◉● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_3.txt b/codex-rs/tui/frames/dots/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..062da3ed89fc3d07e75cdd4b292bd3bbc67fa637 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_3.txt @@ -0,0 +1,17 @@ + + ○◉○◉●●○○○○●○ + ○●●◉○··●· ○○·◉○○·●●○ + ●◉◉●◉·● · · · ●●●◉○·● + ◉●◉○·◉●●○ ·○● ··○ + ●◉●·◉○◉··●○○ ○○○··○ + ◉·○○◉ ◉●·○◉○○ · ○○· + ○● · ●●○·○ ·● ◉●◉·● + ●◉·●· ●◉· ◉○· ·○·○· + ◉ ●· ◉·●●··●◉·○●●○○○●●○·○ ◉· + ●○◉○● ◉◉●◉◉◉· ·●·○○● ○◉●◉○··○·· + ●○· · ··●● ●●● ●●●◉◉◉●◉ + ●●· ○● · ◉○●○◉◉ + ○○◉◉●○● ●●◉◉ ●·● + ○○●●○●●○○◉○◉●◉○●◉·○● + ·●●●○◉·○○○○·◉◉○ + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_30.txt b/codex-rs/tui/frames/dots/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..4bf02ade3d83e5c3e587ebb06788bbdbdf979a42 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_30.txt @@ -0,0 +1,17 @@ + + ○◉ ●●●● + ○◉◉··○●●○○ + ●○·●○○·●●○ + ··○·◉·○○·○ ○● + ○●···○·●·● · + ·●·○● ●····◉· + ○◉···○○ ○◉○· ○· + · ●●○·●· ·◉ · + ·○ ○·○·○◉·● · + ··○·○○·· ●● · + ○··◉○○●···●·· + ·●◉◉●◉●○·· · + ○·○··◉●··●·◉ + ●○●●○○◉·●◉● + ○◉◉·○○○◉● + ···· \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_31.txt b/codex-rs/tui/frames/dots/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..99385ee51fa5563c12c3d82d38c5ad660e509239 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_31.txt @@ -0,0 +1,17 @@ + + ○◉◉●●●●○● + ○●●◉●●○ ●○●●● + ●·○◉◉●○○·◉ ·○●○ + ●·◉●○○●◉○●○○·○··○ + ○····●◉●○○● ○·◉○○ + ◉·○··○·● ○◉●●◉◉·● · + ◉·· ·○●·○◉○◉◉●·●·◉· + ○○● ·· ·○ ·○ ··○◉· + ·○●○·●○···◉●···○ · + ···◉◉··○○○○○·○·· ● + ○●○◉●·● ◉●●●·◉·●·○ + ···○○○● ○○● · + ◉·●○○ ● ◉ ·●·◉· + ◉·○·◉○··●· ●·· + ● ○·○●●◉◉◉● + ···· \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_32.txt b/codex-rs/tui/frames/dots/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..771e9c9106b0d64c05db70b2b4e6b2d30b59953d --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_32.txt @@ -0,0 +1,17 @@ + + ●●●●●◉◉·●○ + ◉●·●●·○··●●··● + ◉○●○○●●○◉◉·●·●○●●● + ●◉◉·○●·◉◉ ◉ ●○○·●● ● + ◉◉·●·●○●●·◉ ●·●·○◉○●○● + ○◉○○●○●··◉··●○ ·●◉ ● + ●· ◉●·●○··●●·●○·◉○◉◉·●○ + ···●○ ○·· ◉●·◉◉○ ○· · + ○··◉○ ●○· ○○·●●●···○· ○· + ● ○··●○●○○○○○○··●·◉·◉○ + ●○●··◉ ·● ●●●○◉◉◉◉◉◉ + ○●·○○○ ●○·◉●· + ●●●···●○ ○◉○·◉ ● + ○◉○ ·●·◉○●·○● ○◉ + ·◉○○○●●◉●○ + ··· \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_33.txt b/codex-rs/tui/frames/dots/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..4d36c1eb6f265838da12921e700fa5a85b97b12f --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_33.txt @@ -0,0 +1,17 @@ + + ○●●○●○●○·◉○ + ○●●··○●○·◉·◉·●·●◉ + ○◉○·●●●●○ · ●◉··● ● + ●○◉·◉○·●○ ●··○○● + ○◉·◉···◉○○○ ·●○○○ + ◉◉● ○○○○·○● ◉○◉○○ + ··● ○●●·○·○ ·●·· + ●·●● ●●●○◉○● ○ ·● + ··· · ●· ◉●●●●◉○○●··●◉○·○· + ○◉·● ◉◉●◉···○○○○○··●○○○··◉ + ○○○● ·◉·◉ ◉ ●●● ··●◉○· + ○●○●● ● ◉◉◉◉○◉ + ●○ ●●●◉● ○●·●◉●○● + ○· ●○●·●◉●○ ◉●○●·● + ○○●●○○○○●●◉◉ + ···· \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_34.txt b/codex-rs/tui/frames/dots/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..4cbd99c143538619216626cbd3954f2eeeefcdba --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_34.txt @@ -0,0 +1,17 @@ + + ○●○●●●●○·●○ + ○●●●◉○◉· ·●●○●●◉●○○ + ○◉◉●·● ◉·●· ·○○○● ●●● + ●◉○·●○◉●○ ●·●●○○ + ◉◉·○◉◉· · ● ○○●○○ + ●◉○··◉ ○○ ·○●○ ○●○·○ + ·●○·○ ●○○●·○○ ·◉·○ + ○·○ ◉ ·●○·◉ · ◉ ○○ + ●●◉◉ ◉○◉◉ ○○○●●●●●●·○○ ·○· + ··○●◉●●○ ◉◉●···○·◉○○○●◉○····○ + ◉○○● ·○·●○ ●●····○ ●◉●○·● + ◉●○◉○○○ ◉◉◉·◉● + ○●··●○◉● ○●○ ○◉●· + ◉○◉· ·●●◉●●○●○●·●●◉● + ·○◉○○○○○○◉○●○○ + ··· \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_35.txt b/codex-rs/tui/frames/dots/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..5ccdf711b5bc17f9ed588a5265a291d9962eab0d --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_35.txt @@ -0,0 +1,17 @@ + + ○●●◉○●●●○●●○ + ●●○●◉·●●● ·●·◉ ·●○○ + ○◉◉·◉●●·○● · · ●○●◉ ·●● + ●◉◉●◉●◉●○ ●●○○·○ + ◉◉··◉◉○ ○●● ·○●·● + ◉◉◉◉●· ●·○ ◉◉●○ ·○○· + ○ ◉◉ ○○ ●●○◉ ·○○○ + ···◉ ◉○○◉◉● ◉ ◉ + ◉ ●○ ●··◉◉ ◉·○○○◉○○●·· ··○● + ·●○◉● ◉◉◉ ●●◉·◉ ○●○○○○◉ ·◉● ·● + ◉·○●●◉○○·◉○· · ○○○○●○○●◉◉○●· + ◉○○◉· ○◉● ●○ + ○◉·○◉●◉● ●◉◉○●●● + ○○ ·●·◉◉··●·◉◉● ○◉◉ + ○·◉○○○◉○○●●● ○ + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_36.txt b/codex-rs/tui/frames/dots/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..6a26abaea68ed2a8d35e195bc0c5dcb54430a0b3 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_36.txt @@ -0,0 +1,17 @@ + + ○○●○●●●●○●●○ + ○○ ○●◉◉◉●○●◉●● ●●●○ + ●○●○●●●◉● ·· · ●·●●·●●·● + ○· ●●○○○○ ●●·○ · + ●◉○◉○●·○·○●◉ ○○◉● + ◉ ●◉· ●○ ·◉● ○○● + · ◉ · ○ ○○ ·○·● + ··○· ○○ ○◉◉ ○· · + ·● · ◉◉ ●● ●●●●○●●●●●○ ·○·· + ○●·○ ●·○○◉·◉ ··○○○○○○○·· ○◉●· + ● ●·○ ●○●◉○ ●●○○○○○ ·● ◉ + ●● ○◉◉ ●◉· ◉ + ○◉··●·● ●●● ◉●· + ●●○ ●●○○◉·○●·◉ ◉ ●◉●● + ·●●◉·○●●○●●●●●● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_4.txt b/codex-rs/tui/frames/dots/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..b4496013b5e649a3591603dc02a53d8ddb74d580 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_4.txt @@ -0,0 +1,17 @@ + + ○◉○◉●○○●○●●○ + ○◉ ●●·◉ ·○○◉●◉· ◉●●○ + ●●○···●● · ·○·○○●·◉○○○ + ◉◉·●· ◉●● ·●●○●○ + ◉·◉○·○·○◉○·● ○ ○·●○ + ◉··○· ●○··◉●○ ● ○○·● + ○·◉· ●●●○● ·● ● ◉· + ·◉○ ●·◉●○·· ··· + ○· ·○ ◉·◉ ○◉◉●·●●●○○○○●● ··● + ·○◉ · ●◉◉◉●○··○○·●◉○●◉◉○◉·◉◉ · + ··◉●○●○○●○◉ ●●●●●●●●○◉···· + ●·○ ·○· ●○◉◉●●● + ○○●◉○ ●○ ○◉○●●◉·◉ + ···○○●●○◉◉○◉●●○●●○·●· + ●○○·◉·○○○○○◉◉●· + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_5.txt b/codex-rs/tui/frames/dots/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..0905c495b266c3fd0bae3950e0dee110b416e2e1 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_5.txt @@ -0,0 +1,17 @@ + + ○◉○◉◉●○○○●●○ + ○● ●◉●◉··●·○●◉○· ●● + ●○◉○●·● · ○○·●○ ●●● + ●·◉·◉·◉○● ◉○····○ + ●○◉●◉○○●·○○● · ○·○·○ + ····◉ ○○·○◉◉·○ ●◉·· + ·◉· ● ·○○·○ ·● ●●·●·● + ·◉·○ ●○○·◉○· ●·○· + ·●·○● ◉○· ●·●○○·●·○○○●●○ ·◉· + ○○·○●●◉●● · ◉◉○○○●○◉●◉·◉●·· + ○●··●·●○○◉●· ●● ●·◉·○◉ + ○○●◉○○ · ◉○◉●●○ + ○◉●○·○●○ ○○●●●●◉● + ○·●·●●●○◉·○○●●○●○·◉● + ○◉●○● ○○○◉○●●●· + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_6.txt b/codex-rs/tui/frames/dots/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..3f96b6676177ad9eff0eeeed6ade05dbff00d632 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_6.txt @@ -0,0 +1,17 @@ + + ○◉○◉◉●○○●●○○ + ●○◉◉●·○●●·●●◉○●●●○ + ●●●···● · ·◉○●●●◉○○ + ◉●·●·●○○● ○●····● + ●○···●○○●●○ ◉○○·○● + ◉◉◉·◉·○●◉·○○·● ◉○○◉· + ○ ●·· ○○·◉○○ ○●·○· + · ··· ○·○·◉·· ·●○·· + ○◉·◉· ◉●··●◉◉●○·●·○○●●○◉○◉· + ··○●◉○○○◉● ·◉○●●··●◉○○○·●·○ + ◉ · ◉···◉◉ ● ●● ·○○◉· + ● ◉ ◉●· ●◉○◉◉·· + ◉·○○●●● ○●○○◉●●● + · ·●● ● ◉·○○●●●● ◉◉● + ○◉◉●●●○○○·○●●● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_7.txt b/codex-rs/tui/frames/dots/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..aa52e1b869d18fabb07a79dffd38e86f1e759a47 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_7.txt @@ -0,0 +1,17 @@ + + ○◉·◉●●○○●●○ + ●●·●○·●●●○○○◉ ·●● + ● ●···●· ·○·· ●◉○●● + ○○····◉● ○·○●·◉● + ·●·●··○·◉○● ◉ ●··○ + · ◉·· ·●○◉·○ ●◉··· + ·○ · ○○·○·○· ●●○·· + ○ ◉·· ○●◉·●·· ● ··· + ◉ ·● ● ○◉·○◉··◉◉○··○◉●○·· + ·●●··○◉··◉●·◉○··○○●○●◉○·○· + ○ ○○◉●·●○◉ ●●●●●● ○◉·· + ●○ ◉○◉○· ● ◉◉◉◉● + ●◉●●○○○○ ●○·●●◉●● + ○○●○·●○◉·○○○●●·●●◉ + ·● ·◉·○○●○◉●● + \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_8.txt b/codex-rs/tui/frames/dots/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..5791ce70e4801bca8eebc9b24d0f72da3fd29077 --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_8.txt @@ -0,0 +1,17 @@ + + ○○●○◉●●○●●○ + ● ●◉···●○○ · ● + ○·○●·◉◉● ● ·◉·●○●○● + ○●○●◉·●●· ● ○○··○ + ● ◉·◉●○·◉○ ◉●○··● + ·●●·○○○○○·○● ○●··· + ·●··· ●○◉○··· ·○·◉·● + ○●··◉ ·●○◉·●●◉ ◉·◉·· + ○ ○··○○ ◉●○··◉●●●●●○··· + ○●◉○○· ●·●··◉·○○○··○● + ◉◉○○·○···◉●○●○● ○● ◉○· + ●● ·○··· ◉●◉◉◉● + ●○○●○○○○ ○◉○○·●◉● + ◉◉●·●● ◉◉●●◉◉○●·· + ··○●●○·○●●◉○· + ·· \ No newline at end of file diff --git a/codex-rs/tui/frames/dots/frame_9.txt b/codex-rs/tui/frames/dots/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..35588ee1ee76cc6dc2b401c0ddf6bf822dd0e96d --- /dev/null +++ b/codex-rs/tui/frames/dots/frame_9.txt @@ -0,0 +1,17 @@ + + ◉○○●◉●●○● + ◉●○◉··● ○ ○◉○○ + ◉ ●··◉●○○○◉ ○○·● + ··◉◉○○ ○○●·○○·●● + ◉● ◉○● ·●● ●◉●●◉●·· + · ··· ○·○◉○○ ·●·●··· + · ·●○·●●·○○● ◉●◉ ··· + · ·◉○●○···○◉○○○· ·· + ●· ◉· ····●··●··· + ·○···○●·○·○····○○··· + ·○◉○○·◉ ●●●●·· ◉○· + ○···○●· ●◉ ○◉·· + ○●○·○○ ◉◉○◉·◉● + ○◉ ○○·●◉●○◉◉●·· + ●◉○ ●○○·◉●◉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_1.txt b/codex-rs/tui/frames/hash/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..45adbbac24792ff52a27eb6762441947bdfd6773 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_1.txt @@ -0,0 +1,17 @@ + + -.-A*##**##- + -*#A**#A#**..*- -█#- + #.*.#**█-- -█*-█.*...# + **-**█##- A*.*.# + *-*A█-.*-** █..**# + .* #- .*A*..# █.*..# + #-█-* █*.*A█.- .A. + ..-.- #AA.*.* █-. + .*.█- *..-*.█..######-## *█ . + -.** -*#- A* .█.---.A###.A#A#. + *--█- -*#.A- --*██* -*█A#*-A + *-# █# - #█A*-. + -*#-*#- -*-#.-#█ + -*#*- *#A****.**#-#.* + -*█*...---#-*#*█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_10.txt b/codex-rs/tui/frames/hash/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..0e9a76d4d8f137b65afcb55a6196d617797e8433 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_10.txt @@ -0,0 +1,17 @@ + + -#****##- + *█-#*#*.A-*# + --#..A..-.#*## + .--A*.*-.*#██**# + A *..█.- █#A-A.A + .- █.*.AA* --█. + * .*█*....* .A# + █ ..*#A#..---.*.. + █ .* **.*..#*#*.. + . #.*#.-A-*---.*- + # █.....██ *#█*.- + *-█#*#*█ -.██#AA + .█ ....*#A#A.A- + *-**A.#*- AA█ + *# -**-#** + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_11.txt b/codex-rs/tui/frames/hash/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..b7e743b218b00fabf665666d9935397d38919e34 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_11.txt @@ -0,0 +1,17 @@ + + #****##- + A█ *...**# + A█--..***A*# + .--..**#-*AA + -█.**.#*█*-..# + .#-..#-**.-A.. + ** #......-.** + . ..A..*A .█. + A █.A..*A#.-. + ** -.A*#█A.-.- + █-- ....*A.**. + *--A.-*A***.- + - *.**--.*█ + *# *..#-A*- + *- █*#-#- + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_12.txt b/codex-rs/tui/frames/hash/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..0c6c85043f943d450af2e6e6362b98867d5a9396 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_12.txt @@ -0,0 +1,17 @@ + + #***#. + #*--A.#* + █ .A*## + A#...**█. + . █.....A. + .█..*-█-.█ + . .A*..* + . A#*..█ + *# .A..#.. + . A.***- + .. .█***A + # *. -#. + A **.#**. + █-A█#.*.- + * █.*.A + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_13.txt b/codex-rs/tui/frames/hash/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..097cd508d7ec35d197f03b280f2f9c1d0d23762e --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_13.txt @@ -0,0 +1,17 @@ + + A***# + .--.. + .--.█ + .**.- + * .. + █A-.. + # .. + # █. + A# -.. + .█ #*. + █-.. .- + .---. + .##.- + *--.* + # .█* + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_14.txt b/codex-rs/tui/frames/hash/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..8eca909504070cc5451226ed9936f643c59226f2 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_14.txt @@ -0,0 +1,17 @@ + + #**** + #A--.* + .-... . + ..A.█-.# + ...* . + .*.. █. + .... . * + ..*.- * + .... █ + █-AA *A + *.██#.#. + .*..---. + A █.***- + .*A*--A + -* █*A + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_15.txt b/codex-rs/tui/frames/hash/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..cbf646ab35c045648d9452a80e04afcf0d6660c1 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_15.txt @@ -0,0 +1,17 @@ + + ##***.- + -#*..-A*# + AA.***.█*. + .A- AA#.--. + ..*█**..A█-. + *..-..AA. █ + .. ......#. + ..A..#... █-. + ..###*...-.A. + .-.*A.*.*. .. + .A....-. - * + .**.-..*- * + █.*# AAA- *- + .-..**.#*A + *-*----A + -- \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_16.txt b/codex-rs/tui/frames/hash/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..82698755af176aa698c2717c9c179b5b3aa1d2fa --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_16.txt @@ -0,0 +1,17 @@ + + -*#█**#.- + A-..*..*.** + .AA█*A**.. █* + AAA **-*.*..**. + ..*-A*█*..*A.-* + ...*.█ .** . ... + **. A-#....A*█# . + .A* .-..A...█* -. + **...#.A-..█*# A + *█--#****..-. #-. + ...#██A**.*█*...- + ..* .- -AA# A + *.* . A#A-#. + *..█-*.AA#-. + █A.-*A--** + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_17.txt b/codex-rs/tui/frames/hash/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..57d02179e70736cad84fb093f4a1f13bfd58c6e4 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_17.txt @@ -0,0 +1,17 @@ + + #*###**.#- + -***...***.#█-# + #**A-**-##█...-█* + A.A-A.█-- █.**...** + .A- . .█A***AA # + -**#A- #AA. A#*A..A + * *A. #AA.- *█..A*. + -█*. AA..A.#..*** + #A*A***#.*-.*-A*... . + .█-*--.A**A..* *#█#* + ***A███*****-.*AA* *█ + *..-* -A..- * + -** **- #█#A--A + # .*#**#--**A.█ + -#*A-##.*-#* + - \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_18.txt b/codex-rs/tui/frames/hash/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..ef524a0ed9100a138d9d971bd07da693aa62fb39 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_18.txt @@ -0,0 +1,17 @@ + + -**#**#*.#- + -#█-*###.--#█#**- + -AA.*█**█#█.-█#AA* * + #..*.#-█* █*--A*.*****# + -*AA A. -█#-*A.A.-****- + A*. ##..---A#*#.█-A-A-A. + ..*AA #A*A.█-A█*.*.*# + █*-.. -*█..*- .*.█ + *██ #******#..#.*A*# .*.- + -**.*** ..A--*-...-*.-* # + .A.*-██*****- *--A.A*A--- + ** #*# --*█A*-█ + *-*#--. -*--A*#- + █..**##***-█*-A-#* + █..-A--#.#█*#█ + - \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_19.txt b/codex-rs/tui/frames/hash/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..80a9abf0128d75ba066ed482505aeb66109edd16 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_19.txt @@ -0,0 +1,17 @@ + + --**#.#**..- + -#...*A***-*-#.*## + #***A█.#*- -*A#-*** + A*.*#-- -**##-.*# + ..A-A- #.-█A█.* .*# + .*A*█ -.*.A .A █.*. + -*..█ A*-A.##- AAA#. + .... .*█*.█# . ... + .*.*#******....#***.* * █.. + -*A#.**A-*#*#-# -- A*-*#A..- + █*█A#.█████**█ -*-A.AA AA█ + **-*-* -AA-AA█ + █ -*..*# #**.A.*- + ██..*A.#--**A*█ **.█ + █..-A-█-#.-*-- + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_2.txt b/codex-rs/tui/frames/hash/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..843df90f283fc22a2c9b8516e051741792f65bc4 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_2.txt @@ -0,0 +1,17 @@ + + -.-A*#***##- + -##A**#...*..*-█.*#- + #.*.***.*-- -█***A..*.# + -.A-A*█##- -#*.A.# + #-█A*-.*-**- -#.*.# + █A#█- .**#A*# **.*. + #.█*. -A.*.-.# A█.█. + .█ *- #.*..** .█A.. + *██*. *.---.█..#########.-..* + -*█. A..█ A* .**----..--.#A . + *** * **...█ -█*******█.#*.. + █*-.A# - ##*A#* + .*--.## #*-# -.█ + -.-*--█*#A****.**--..* + -*#*#..----.*A*█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_20.txt b/codex-rs/tui/frames/hash/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..b588df38946d08e898a0eba4023ee1feca966339 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_20.txt @@ -0,0 +1,17 @@ + + --#*..*#*#-- + #**█*#-#***..--**## + ##█-#*█.*█ -█-█*#*.#- + -.█-*.* -##.*A*** + -. #.* -A -..-**..* + A-#.█ #*█*.█A █*..* + *.*#. #* .#*- #*.A + .--A- ██*A.█# ..█. + .A..*#********-#█*--*** A .* + █.-.-█ A------.* ***█.** #A .. + *#-*████████*█ *#*..#. A* + * -*A# -A*..A + *--*#.- #**-**█ + -#-.█.#-**#A█.█-#A*█ + -*#.- *----.*██ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_21.txt b/codex-rs/tui/frames/hash/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..0d1fc7ec26ed7e8c6a400e64ab8baf2960afb034 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_21.txt @@ -0,0 +1,17 @@ + + --#*#**#*##-- + ##*----#.*.#*---*#- + **--#*-█- --.*#--*# + #*.*██ -#**#**# + A#A*- #.A-* **--# + A█A. #**-AA **-# + .█.- - A-#A█ ***█ + -* ** █.# . . + . . ##*****### -.#█.# *A . + . .* .- -#. -█-*.# A█.A + * █. --███ █- █*-#* A- # + █# A*- -*█ A + *- --- -.**█ #- + **- *█*#A..*A**██ -- + *##*#----#-*#*- + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_22.txt b/codex-rs/tui/frames/hash/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..8fbfdb57138214a50f3e25ea96f2647aed0e3f39 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_22.txt @@ -0,0 +1,17 @@ + + -##*#**#*##- + -#A*..-A.A*..*.#A*#- + #***.***- -█****#--## + **.**█ ##...-** + *..A█ #-A.*..A*█. + *.*A- #.A.*-A- *.**. + .#**A .#.**A█ ..A. + ..*.- .*..-* .-.█. + ..#█# #.******##-**.- *# -.*█ + -.* *-**# A#█.AA. ***-█.## A** + ***-. █████**█- -A*#-AAA█#- + ***-*# -*#█.#█ + ██#* ##- -.*#█*-A + █A*##█*.**#**A**███#* + █.█.-*----*-.**- + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_23.txt b/codex-rs/tui/frames/hash/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..ef2f8adb70959f1d0279b62377e3eae9d4723796 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_23.txt @@ -0,0 +1,17 @@ + + -##-*#*##*#-- + -*.#.*-#-**#.-.*.#- + #█#.█#**- █.A#.**..# + A*.**A- -***..** + AA.-A█ #A##█*A**.* + #*.█A- -***██A*█..*.# + -..█. AAA. -.- █ .█* + .A. . .█.#-*# . *A. + -█* .#.********.-*.-█*- . ... + .**-.█*█ A-* *.-*#. *-. + A█. . -███████ █-A**.-A-A- + #..-.. #A.*-A- + * .#█A-- -.█**A#A + ***#**#*.**-##█--#*- + █. ***.--#--**- + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_24.txt b/codex-rs/tui/frames/hash/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..09a7fd520cb9f1d2b5d4498b62ab0e43119d30c4 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_24.txt @@ -0,0 +1,17 @@ + + -.-*##***#- + #█-A█.--.**##-█# + #██**A#█. .-A*.**# * + #█AA#A.*#*-.A- -**.*-█ + # AA*A# -██A*-.--*.#█ + .#*#*- *##A ..* A█* + . .. #A-#A█A-*.*-.- + . *.. .#*A*-..-A.-.. + .. .***A##...##-*#-. . .. + - A *.A##**##..-*#*. .*. + * █*.**██████- *#.**.*A#- + * *#** . █A*A + *.**-.. -*█-AA-* + .-*#*-█..A***.--A- + *A-*#**--**#* + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_25.txt b/codex-rs/tui/frames/hash/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..af8bb947f60cc641c7102ca2f4afcecf4660ce1a --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_25.txt @@ -0,0 +1,17 @@ + + -#***####- + # *#*--**.*..# + A.AA##█..#***...- + .#*. A *. █*.#**A + #--*#A█-*A-.█ .AA*A* + *#...-*#.-.A A.#.AA.# + .A.*. .--A*-A.*A-█.*. + ...A..█ .-**A.*-.*..* + -#*.█.--****#..**.#*.* + A-█.A.#.-..**-.***A*. + *.*A*.-██████#A.*.A.- + .-█.*#**-#*A-A#AA.A + ---.-*-**A#-A-..A + *.***-**.** *.* + .#█**##-#.* + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_26.txt b/codex-rs/tui/frames/hash/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..7ff85c300af4473eaa41cce34ca5ab33dce9b0d7 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_26.txt @@ -0,0 +1,17 @@ + + -****█## + A*.*A*.#*.# + A*██.**.*██*# + **█#...-..-#A- + .-#*...█**A*.** + * █.AAA█*-*█**A + . *█*..A..**█# + A- *...--*..A.. + . .***#.#**A*.. + .-.A#.--#****. + .-█A.*█*#A-#*.. + *-..A* AA█..- + * *.* --A.A# + .█***█*-*** + *AA-*.-** + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_27.txt b/codex-rs/tui/frames/hash/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..06e988b0761dc327a2847de428b7c1a60993aa2e --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_27.txt @@ -0,0 +1,17 @@ + + ****## + .*AA.A* + *█- *.A█. + ...-..*.. + . -.--█* + .-.-█-...- + * █..-*.. + . -...*█.. + .. . .#.... + █* .█-.-*.. + .- --**#A█ + ..##..-A. + .**#*#A.- + *--.**AA + █*█ -*A + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_28.txt b/codex-rs/tui/frames/hash/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..0e258181458faad24de8e067839f034e90439961 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_28.txt @@ -0,0 +1,17 @@ + + A*** + .#-.- + * *.- + .--*. + . .. + . -█. + -.. -. + .-██. + A* -. + ...A.. + . -. + .*#*. + .**.. + *--A. + . .#. + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_29.txt b/codex-rs/tui/frames/hash/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..7f2ddab00a4ff160fdc7355f74fc4687f8e9018a --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_29.txt @@ -0,0 +1,17 @@ + + #****# + #.*A ** + -#.. ██. + ....-*#A + * .█. . + .#... -- + .**... - + ..*.. + ...*. - + -.*.. - + *..A.- * + .A... -. + ....-.-. + .*.*A█.█ + ..* .# + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_3.txt b/codex-rs/tui/frames/hash/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..8cce426bb4a573756a6bad60a4413899a4be4926 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_3.txt @@ -0,0 +1,17 @@ + + -.**##****#- + -##A*..#.█**.*--.*#- + #*A**.*██- -█. **#A-.# + A#A*.*##- -** ..* + █A*.A-A..**- *-*..- + A.**A .#.**** . **. + ** . █**.-█.# .*A.# + #-.#. #-.█A*. .-.*. + -██. *.*#..*-.*##---##-.-█*. + #*A*# .A*A**. .*.--# *.#**-.*.- + █-. . █..##█ █***███**#AAA#A + █*. *# - A-#-AA + *-AA**# ##*A█#.█ + *-##-**-****#A***--#█ + -*#*-A.----.*A-█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_30.txt b/codex-rs/tui/frames/hash/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..24a2165e45b9ae94cfdc74a57673befe1418b074 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_30.txt @@ -0,0 +1,17 @@ + + -*█**## + -A*..**█** + ██-.#**.##* + ..*.A.-*.* *# + **..-*.#.# . + .*.-# *...... + *A...*- *A*. *- + █. █#*.#.█.- - + █.-█*.*.*A.# - + █..*.--.. ## - + *..**-*...#.. + .*AA#A**..█ . + *.*..A*..#.A + #-##*-A.#A█ + *-..---*█ + ---- \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_31.txt b/codex-rs/tui/frames/hash/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..65f139ab962f27468c1472497cc6ba36092e3b84 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_31.txt @@ -0,0 +1,17 @@ + + -.A*#*#*# + -#**##-█#*█*# + #.*AA#*--.█.-** + #.A#--**-#**.*..* + -....#-**-#█*.A-- + *.--.-.██***#.A.#█. + A.. .-*.*.*A**.*.A. + **# ..█.* .-██..*.. + .*#-.█-...A#...- . + █...AA..---*-.*.. * + -*-A#.# A***-A.█.- + ...***# -** . + *.**- # . .*-A- + A.*.A-.-#.█#.- + ██*.-#*A.** + ---- \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_32.txt b/codex-rs/tui/frames/hash/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..6cbec21aecaa0b8ec6ca0a1737e2cac919632c71 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_32.txt @@ -0,0 +1,17 @@ + + #####**.#- + **.**.*..**..# + A-**-##-A--*.**█*# + █AA.-#-*- - #-*.## # + AA.#.**█*.* █-#.*A***# + *A**#**.-A..#- .#A * + *.█*#-█*..##.#*-A-AA.█- + ...#- █-..█A█.*--█*.██- + *..-- █-.█--.###...*.█*- + ███--.**█*-----..*.*.-- + #**..A .*██***-*AAA.A + *#.-** #*.A█. + █**...#- -A-.A # + *-*█.#.***--#█-A + .**--##A*-██ + --- \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_33.txt b/codex-rs/tui/frames/hash/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..a661feb2aff3ca66e22dc96557e91d5928c649e8 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_33.txt @@ -0,0 +1,17 @@ + + -##*#*#*..- + -#*..***.A.-.*.*. + -A*.#*##*█- █*A..#█# + #*A.A-.#- *..*-* + -..A-..A*-* .#**- + AA# *-**-*# A*A-* + ..# *#*.-.* .*.. + *.## #██*A*# *█.* + ... . #- A*###***#..#A-.*. + -A.██AA#A-..-----..*---... + ***# .A.A A ████*** ..#A-- + *#**#█# AA*A*A + **█*#**# -#.#A**█ + *.█**#.#*#-█*#**.* + █**##----#**-█ + ---- \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_34.txt b/codex-rs/tui/frames/hash/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..3427025326ca225b0b8a2ac6da1ffa4431acab87 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_34.txt @@ -0,0 +1,17 @@ + + -#*####*.#- + -#*#A**.█.#*****#*- + -AA#.*█A.*- --**#█**# + #A-.*-A#- *.#*** + AA.*-A. .█ # ****- + #A*.-A **█.*** *#*.* + .*-.- █*-*.-- .*.- + *.*█A .*-.A█. A -* + #█A.█ A*AA█-**######.--█ .-- + ..*#A##-█AA█...-..---#A-..-.* + A-*# .*.*- █**----- #A#*.█ + A#****- A*..A█ + *#-.#**# -#*█-A#- + -*A.█.##*##****-#*A█ + █.***-----****- + --- \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_35.txt b/codex-rs/tui/frames/hash/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..e0919ec5d0e569ed111bad8275f8a5e1d2ec1b29 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_35.txt @@ -0,0 +1,17 @@ + + -##**###*##- + #***..**#█.*.A█.**- + -AA.*#*.-*█- -█***.█.*# + #AA#A#*#- *#-*.- + AA..AA* █**# .**.# + *A*A#- █.-█..*- .**. + * .A ** █*-. .-** + ...A A***A█ A █A + A #- #..A*█A.**-*-*#.. ..** + .#-*█ AA*█*#A.* *#----A█.A█ .█ + A-*#█A*-.A-- -█----*--*AA-#. + █.-*A. -A* #- + **--*#A# #*A-**█ + █--█.*.-A..#.*A*█-A*█ + █-.**--A--##*█- + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_36.txt b/codex-rs/tui/frames/hash/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..0355f68b47c3315bfe43ab01d9a51405df7ce9b7 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_36.txt @@ -0,0 +1,17 @@ + + --#*####*##- + -*██*#A*A#*#*##█**#- + #*█*##*-*█-- -█*.*#.#*.# + -.█*#---- █*.*█. + #A-**█.*-*#. -*.* + A #A- *- █.A# █*-* + . * - * █** .-.# + ..*. -* -AA *. . + .██. AA #*██###-#####- .*.. + -*.* #.--A.A -.-------.. *A█- + █ *.* #-#A- █**-----█ .#█A + █#█**. #A.█A + *A..#.# ###█A#- + *#-█***-*.-#.*█-█#*#█ + -*#A.-##-####**█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_4.txt b/codex-rs/tui/frames/hash/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..2b4b7c670bb49a66f35f9b4b8e230a5a3483fd0e --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_4.txt @@ -0,0 +1,17 @@ + + -.**#**#*##- + --█#*.A .**A**.█A*#- + #**...#*█- --.*-*.A**- + AA.#.█.## █.***** + A.A-.*.**-.# *█*.** + A..-. █-..A** █ **.# + █*.*- █***#█.# # █*. + ..* #.A*-.. ... + -. .- *.A█-AA#.###----## ..* + .*A . #AAA#-.-**.#.-#AA-*.AA█. + -.*█*#**#*A █*******█--...- + #.*█.-- #-*A**█ + **#A- #- -.-#**.A + ...*-*******##**#*.*- + ***.A.-----*-*- + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_5.txt b/codex-rs/tui/frames/hash/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..c71575690bbefeb4c54ef43d78d0efd980b5fa1b --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_5.txt @@ -0,0 +1,17 @@ + + -.***#***##- + -#█#A*A..#.*#*-.█## + *-A*#.*█- █-*.** #*# + #.A.A..*# --....- + #*A*A--#.-*# - *.*.- + ....A *-.**A.- #A.. + .A. # -*-.*█.# ██.#.# + .A.-█ #-*.A-. #.-. + .#.-* A-.█#.**-.#.---##-█... + -*.*##A#*█ .█ AA**-#*.#A.A*.- + *#..#.#**A#- █**█████#.*.*A + **#A** - A-A*#- + -A**.*#- -*##*█** + -.*.#*#**.*-##**-.** + --*-*█-----##*- + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_6.txt b/codex-rs/tui/frames/hash/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..799e3a1cf5ac27cd6d474f7d55921e0a9647b34a --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_6.txt @@ -0,0 +1,17 @@ + + -.***#**##-- + #-*A*.**#.##*-#*#- + ###...*█ -█..**#*A*- + A#.*.*-*# *#....# + █*...***#** .**.*# + *-A.A--#*.*-.# .**A. + - #.. -*.A-* -#.-. + . ... -.*.A.. .#*.. + -..-. A█..**A#-.#.--##-.*A. + ..*#A---*# .A**#..#****.#.* + A . A...*A█ █*████*█-**A. + # * A#- #A-AA.- + .-*-**# -#--A*## + -█.*#█# *.--##** A*█ + --**##-----##* + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_7.txt b/codex-rs/tui/frames/hash/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..4a3f9f202fbd9fe87855d956f307efdb4b5f9e8c --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_7.txt @@ -0,0 +1,17 @@ + + -..**#**##- + #*-#*.#*#**** -*# + * #...*- --.- *A-*# + **....*# -.**.A# + -█.*..-.A*# . *..* + . *..█ -█**.* #-... + -*█. --.*.*. **-.. + -█A.. *#A.*.- █ ... + A█.* #█-A.*A..**-..****.. + .#█..*A..A█..*..**#*#**.*. + * *-A*.*-A ******██-A.. + █- A-A-- #█-A*A█ + █*##***- #-.#*A*█ + *-█*.***.---#*.*#A + █.*█.A.--#-*** + \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_8.txt b/codex-rs/tui/frames/hash/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..4bc5a6f1186d89a862ec38b46e975910aeb1d4bd --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_8.txt @@ -0,0 +1,17 @@ + + --#-*##*##- + #█*A...*** . █# + -.*#.AA* #█.A.**#-# + -***A.##. █ *-..* + █ A.A#*.A* .█*..# + .█#.*---*.-* *#... + .*... █-**.-. .-.A.# + *#..A .#*A.#*. .-A.. + * *..-- A**..A#####*... + **A*-.█#.#..*.*--..*█ + AA**.*...A█-*-*█**█A*. + █#█.*.-- .#AAA█ + █*-****- -A*-.#A* + .A█.#* **#**A*#.- + ..-***.-##A*- + -- \ No newline at end of file diff --git a/codex-rs/tui/frames/hash/frame_9.txt b/codex-rs/tui/frames/hash/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..db3507db59c7a2cfd70f3777cefe1554571fb737 --- /dev/null +++ b/codex-rs/tui/frames/hash/frame_9.txt @@ -0,0 +1,17 @@ + + .*-*A##*# + A*-A..*█* --*- + A #..A*--*A *-.# + █ ..A**- --#.*-.## + A*█***█.*# *.█#A#.. + . ...█-.**-- .█.*... + - .**.**.**#█.#A -.. + . .A-#*...-A**-. .. + █#.█ A.█....#..#..- + .-...-#.-.-....--... + █ --A**.A█****..█A*. + *...*#- #A -A.- + *#*.** A*-*.A* + --█*-.*A#****.- + █-- ***.*#A█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_1.txt b/codex-rs/tui/frames/hbars/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..ab8be3eb1e1619b1ece8502cc7eda6323b8a6ac9 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_1.txt @@ -0,0 +1,17 @@ + + ▂▅▂▅▄▇▇▄▄▇▆▂ + ▂▄▆▅▇▃▇▅▇▃▄▁▁▄▂ ▂█▇▂ + ▆▁▇▁▇▇▇█▃▂ ▂█▇▂█▁▄▁▁▁▇ + ▄▇▂▃▇█▆▆▂ ▅▇▁▄▁▆ + ▃▃▄▅█▃▁▃▂▃▃ █▅▁▃▃▆ + ▁▇ ▇▂ ▁▇▅▄▁▁▆ █▅▃▁▁▆ + ▇▃█▆▇ █▃▁▇▅█▁▂ ▁▅▁ + ▁▁▂▁▂ ▆▅▅▁▄▁▇ █▂▁ + ▁▄▁█▂ ▄▁▁▃▃▁█▅▁▇▇▇▇▇▇▂▇▆ ▄█ ▁ + ▂▁▄▇ ▂▄▇▂ ▅▇ ▁█▁▂▂▂▅▅▆▆▆▁▅▆▅▆▁ + ▃▃▂█▃ ▃▃▆▅▅▂ ▂▃▇██▇ ▃▇█▅▆▄▂▅ + ▇▃▆ █▆ ▂ ▆█▅▇▂▁ + ▃▃▆▂▃▇▂ ▂▄▂▇▁▂▇█ + ▃▇▆▃▂ ▇▇▅▄▄▄▄▅▄▇▇▂▆▁▇ + ▂▇█▇▁▁▁▂▂▂▆▂▄▇▇█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_10.txt b/codex-rs/tui/frames/hbars/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..5e565ce40b91218d95bb539e2448c92ed89aa1b5 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_10.txt @@ -0,0 +1,17 @@ + + ▂▇▇▇▇▃▇▇▂ + ▇█▂▇▇▇▃▁▅▂▇▆ + ▃▂▆▁▁▅▁▁▆▁▇▃▆▆ + ▁▂▂▅▃▁▄▂▅▃▆██▃▃▆ + ▅ ▄▁▁█▁▃ █▆▅▂▅▁▅ + ▁▂ █▁▇▁▅▅▃ ▂▂█▁ + ▃ ▁▇█▇▁▁▁▁▇ ▁▅▆ + █ ▁▁▃▇▅▇▁▁▆▂▂▅▃▁▁ + █ ▁▃ ▃▃▁▄▁▁▇▃▇▄▁▁ + ▁ ▆▁▃▆▁▂▅▂▇▂▂▂▁▇▂ + ▆ █▁▁▁▁▁██ ▃▆█▃▁▂ + ▃▂█▆▃▆▇█ ▂▁██▆▅▅ + ▁█ ▁▁▁▁▇▆▅▆▅▁▅▂ + ▄▂▇▇▅▁▇▄▂ ▅▅█ + ▇▆ ▂▇▃▂▆▄▇ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_11.txt b/codex-rs/tui/frames/hbars/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..5305252a8d148b415987efd56b0853bcf112ed33 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_11.txt @@ -0,0 +1,17 @@ + + ▆▇▇▇▇▇▇▂ + ▅█ ▄▁▁▁▃▃▆ + ▅█▂▂▁▁▄▃▇▅▃▆ + ▁▂▂▁▁▄▄▆▂▄▅▅ + ▂█▅▄▃▁▇▃█▄▂▁▁▆ + ▁▇▂▁▁▇▂▄▄▁▂▅▁▁ + ▇▇ ▆▁▁▁▁▁▁▂▁▄▃ + ▁ ▁▁▅▁▁▃▅ ▁█▁ + ▅ █▁▅▁▁▇▅▇▁▂▁ + ▇▇ ▂▁▅▄▆█▅▁▂▁▃ + █▂▆ ▁▁▁▁▄▅▁▃▃▁ + ▃▂▆▅▁▂▇▅▇▇▄▁▂ + ▂ ▇▁▃▃▃▂▁▄█ + ▃▇ ▇▁▁▆▂▅▇▂ + ▃▂ █▇▇▂▇▂ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_12.txt b/codex-rs/tui/frames/hbars/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..cebfe226e1ee8cde4a4f77580545026ee46574df --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_12.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▅ + ▆▄▂▂▅▁▆▃ + █ ▁▅▃▇▆ + ▅▇▁▁▁▄▄█▁ + ▁ █▁▁▁▁▅▅▁ + ▁█▅▅▇▃█▂▁█ + ▁ ▁▅▃▁▁▃ + ▁ ▅▇▃▁▁█ + ▇▆ ▁▅▁▁▇▁▁ + ▁ ▅▁▇▄▇▂ + ▁▅ ▁█▄▇▇▅ + ▆ ▇▁ ▂▆▁ + ▅ ▇▇▁▆▇▃▁ + █▃▅█▆▁▃▁▂ + ▃ █▁▃▅▅ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_13.txt b/codex-rs/tui/frames/hbars/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..566cc4ffa308f120a8dd0be998e9ea1e33054a06 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_13.txt @@ -0,0 +1,17 @@ + + ▅▇▇▇▆ + ▁▂▂▁▁ + ▁▂▂▁█ + ▁▇▇▁▂ + ▇ ▁▁ + █▅▆▁▁ + ▆ ▁▁ + ▇ █▁ + ▅▇ ▂▁▅ + ▁█ ▇▄▁ + █▂▅▅ ▁▂ + ▁▂▂▂▁ + ▁▇▆▁▂ + ▇▂▂▁▄ + ▆ ▅█▄ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_14.txt b/codex-rs/tui/frames/hbars/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..380790e11c9dd249821844dc22a3ae49f186d116 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_14.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▄ + ▆▅▂▂▅▃ + ▁▂▁▁▅ ▁ + ▁▁▅▁█▃▁▆ + ▁▁▁▃ ▁ + ▁▇▁▁ █▁ + ▁▁▁▁ ▅ ▇ + ▁▁▃▁▂ ▃ + ▁▁▁▁ █ + █▃▅▅ ▄▅ + ▃▁██▆▅▆▁ + ▁▇▁▁▂▂▂▁ + ▅ █▁▄▄▄▂ + ▁▃▅▇▂▂▅ + ▂▇ █▄▅ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_15.txt b/codex-rs/tui/frames/hbars/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..47d169e98bc1a045fdc0de01621ef3707aef256f --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_15.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▁▂ + ▂▆▄▁▁▃▅▇▆ + ▅▅▁▇▄▃▁█▇▁ + ▁▅▂ ▅▅▆▅▂▂▁ + ▁▁▄█▄▃▁▁▅█▃▁ + ▃▁▁▆▁▁▅▅▁ █ + ▁▁ ▁▁▁▁▁▁▆▁ + ▁▁▅▁▁▇▁▁▁ █▆▁ + ▁▁▇▆▆▇▁▁▁▂▅▅▁ + ▁▂▁▄▅▁▃▁▇▅ ▅▁ + ▁▅▁▁▁▁▂▁ ▂ ▄ + ▁▇▇▅▃▁▁▃▆ ▄ + █▁▃▆ ▅▅▅▂ ▄▂ + ▁▃▁▁▃▃▅▇▃▅ + ▃▃▇▃▂▂▂▅ + ▂▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_16.txt b/codex-rs/tui/frames/hbars/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..3b1fb1fc5d4b06793980e2e0d801fd740e540b73 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_16.txt @@ -0,0 +1,17 @@ + + ▂▄▇█▇▇▇▁▂ + ▅▃▁▁▇▁▁▃▁▄▃ + ▁▅▅█▃▅▄▃▁▁ █▃ + ▅▅▅ ▄▇▂▃▁▃▁▁▇▇▁ + ▁▁▄▂▅▇█▄▁▁▇▅▁▂▃ + ▁▁▁▇▁█ ▁▃▄ ▁ ▁▅▁ + ▃▃▁ ▅▂▆▁▁▁▁▅▇█▆ ▁ + ▁▅▄ ▁▂▁▁▅▁▁▁█▄ ▂▁ + ▃▃▁▁▁▇▁▅▃▁▁█▇▇ ▅ + ▇█▂▂▆▄▄▃▇▁▅▂▁ ▆▂▁ + ▁▁▁▇██▅▇▃▁▄█▄▅▁▁▂ + ▁▁▇ ▁▂ ▂▅▅▆ ▅ + ▃▁▇ ▁ ▅▆▅▂▆▁ + ▃▁▁█▂▇▁▅▅▇▂▁ + █▅▅▂▄▅▂▂▄▇ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_17.txt b/codex-rs/tui/frames/hbars/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..93817e2eaddeb660c0a2d42f42cc0ef5b5ca0f50 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_17.txt @@ -0,0 +1,17 @@ + + ▆▄▇▇▇▄▄▁▆▂ + ▂▄▇▇▁▁▁▇▄▇▁▆█▃▆ + ▆▇▃▅▂▄▄▂▇▆█▁▁▁▂█▃ + ▅▁▅▂▅▁█▂▂ █▁▄▃▁▁▁▄▃ + ▁▅▂ ▁ ▁█▅▃▄▃▅▅ ▆ + ▂▄▇▆▅▂ ▆▅▅▁ ▅▆▄▅▁▅▅ + ▇ ▄▅▁ ▆▅▅▁▂ ▇█▁▁▅▄▁ + ▆█▄▁ ▅▅▁▁▅▁▆▁▁▄▄▇ + ▆▅▇▅▃▄▄▇▁▃▂▁▃▃▅▃▁▁▁ ▁ + ▁█▂▄▂▂▁▅▇▃▅▁▁▃ ▃▇█▇▃ + ▃▃▃▅███▇▇▇▇▃▂▁▇▅▅▃ ▃█ + ▃▁▁▂▇ ▂▅▁▁▂ ▄ + ▂▃▃ ▇▃▂ ▆█▆▅▃▂▅ + ▆ ▁▇▇▄▃▇▂▂▄▇▅▁█ + ▂▇▄▅▂▆▇▁▇▂▇▇ + ▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_18.txt b/codex-rs/tui/frames/hbars/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..03d2c5e94b83e98d2143bccdd3b2326287f067a2 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_18.txt @@ -0,0 +1,17 @@ + + ▂▄▄▇▄▄▇▄▁▆▂ + ▂▇█▂▄▆▇▇▁▂▂▆█▇▄▄▂ + ▂▅▅▁▇█▇▄█▆█▅▂█▇▅▅▃ ▇ + ▆▁▁▇▅▆▃█▇ █▃▂▂▅▄▁▃▄▃▃▃▆ + ▂▃▅▅ ▅▁ ▂█▇▂▃▅▁▅▁▂▃▄▃▃▂ + ▅▃▁ ▆▆▁▅▂▂▂▅▆▇▆▁█▆▅▃▅▂▅▁ + ▁▁▃▅▅ ▇▅▇▅▁█▂▅█▇▁▄▁▄▆ + █▃▆▁▁ ▃▃█▁▁▄▃ ▁▄▁█ + ▃██ ▆▃▄▄▄▄▄▇▁▁▆▁▇▅▃▆ ▁▇▁▂ + ▂▃▃▁▇▃▇ ▁▁▅▂▂▃▂▁▁▁▂▄▁▂▃ ▆ + ▁▅▁▃▂██▇▇▇▇▇▂ ▃▂▂▅▁▅▇▅▂▆▂ + ▃▃ ▆▃▆ ▂▂▇█▅▇▂█ + ▃▃▇▇▃▃▅ ▂▇▃▂▅▃▆▂ + █▁▅▇▇▇▇▄▄▄▃█▇▂▅▂▆▇ + █▁▁▂▅▂▂▆▅▇█▄▇█ + ▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_19.txt b/codex-rs/tui/frames/hbars/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..f82677617003c65f34ab8898ec643e633ff2ec8f --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_19.txt @@ -0,0 +1,17 @@ + + ▂▂▄▄▇▁▇▄▄▁▅▂ + ▂▇▁▁▁▃▅▄▄▄▂▇▂▆▁▇▆▆ + ▆▇▄▄▅█▁▇▇▂ ▂▇▅▆▂▇▄▃ + ▅▄▁▇▆▃▂ ▂▄▄▇▆▃▁▇▆ + ▁▁▅▂▅▂ ▆▁▃█▅█▁▃ ▁▃▆ + ▁▃▅▇█ ▂▁▇▁▅ ▅▅ █▅▇▁ + ▃▄▁▁█ ▅▇▃▅▁▇▆▂ ▅▅▅▇▁ + ▁▁▁▁ ▁▄█▃▁█▆ ▁ ▁▁▁ + ▁▇▁▃▆▄▄▄▄▄▄▁▁▁▁▇▇▄▇▁▄ ▃ █▁▁ + ▃▄▅▆▁▄▇▅▃▇▇▃▆▂▆ ▃▂ ▅▃▂▃▆▅▅▁▂ + █▃█▅▇▁█████▇▇█ ▃▃▂▅▁▅▅ ▅▅█ + ▃▃▃▇▂▃ ▂▅▅▂▅▅█ + █ ▂▃▁▁▇▆ ▆▄▄▅▅▁▄▂ + ██▅▁▇▅▁▇▂▂▄▄▅▇█ ▄▇▁█ + █▁▅▂▅▆█▂▆▅▃▇▆▃ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_2.txt b/codex-rs/tui/frames/hbars/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..d4efa4def0ec379abf2e7d7cb9f1915a95183672 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_2.txt @@ -0,0 +1,17 @@ + + ▂▅▂▅▄▇▄▄▄▇▆▂ + ▂▇▆▅▇▄▇▁▁▁▄▁▁▄▂█▁▇▇▂ + ▆▁▇▁▃▇▇▁▇▂▂ ▂█▇▄▇▅▁▁▄▁▆ + ▂▁▅▂▅▇█▆▆▂ ▆▆▃▁▅▁▆ + ▆▃█▅▇▃▁▃▂▃▃▂ ▃▆▁▃▁▆ + █▅▇█▂ ▁▇▃▇▅▃▇ ▃▃▁▇▁ + ▆▁█▇▁ ▃▅▁▄▁▂▁▆ ▅█▁█▁ + ▁█ ▃▂ ▆▁▇▁▁▄▇ ▁█▅▅▁ + ▇██▄▁ ▄▁▃▃▂▁█▅▁▇▇▇▇▇▇▇▇▆▁▂▁▁▇ + ▂▄█▁ ▅▁▁█ ▅▇ ▁▄▃▂▂▂▂▅▅▂▂▁▇▅ ▁ + ▃▃▃ ▇ ▇▃▅▅▁█ ▂█▇▇▇▇▇▇▇█▁▆▇▁▁ + █▃▂▅▅▆ ▂ ▆▇▄▅▆▇ + ▅▃▂▂▁▇▆ ▆▄▂▇ ▂▁█ + ▂▁▃▄▂▂█▇▇▅▄▄▄▄▅▄▇▂▂▁▁▇ + ▂▇▇▇▇▁▁▂▂▂▂▁▄▅▇█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_20.txt b/codex-rs/tui/frames/hbars/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..30c29f51c9bf26bc89240ac21143a7e679a54bea --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_20.txt @@ -0,0 +1,17 @@ + + ▂▂▇▄▁▁▄▇▄▆▂▂ + ▆▄▄█▄▆▂▆▄▄▄▁▁▂▃▇▃▇▆ + ▆▆█▂▇▇█▁▇█ ▂█▃█▃▇▇▁▇▂ + ▂▁█▂▇▁▇ ▂▆▇▁▃▅▇▃▃ + ▂▁ ▆▁▇ ▂▅ ▂▁▁▃▇▃▁▁▃ + ▅▂▆▁█ ▇▇█▄▁█▅ █▃▁▁▃ + ▄▁▄▇▁ ▆▇ ▅▇▇▃ ▆▃▁▅ + ▁▂▃▅▂ ██▄▅▁█▆ ▁▁█▁ + ▁▅▁▁▄▆▄▄▄▄▄▄▄▄▂▆█▄▃▃▃▃▇ ▅ ▁▃ + █▁▃▁▂█ ▅▂▂▂▂▂▂▁▃ ▇▃▃█▁▄▃ ▆▅ ▁▁ + ▃▆▃▃████████▇█ ▃▆▄▁▁▆▁ ▅▃ + ▇ ▃▇▅▆ ▂▅▇▅▁▅ + ▃▂▃▇▆▁▂ ▆▄▇▂▄▇█ + ▃▆▆▅█▁▇▃▄▄▆▅█▁█▂▆▅▇█ + ▂▇▇▁▂ ▄▂▂▂▂▁▇██ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_21.txt b/codex-rs/tui/frames/hbars/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..b6a6c2c109cb3df2e78cf24cbe05d7e38eb959f0 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_21.txt @@ -0,0 +1,17 @@ + + ▂▂▆▄▇▄▄▇▄▇▆▂▂ + ▆▇▇▂▂▂▂▇▁▄▁▇▄▂▂▆▇▇▂ + ▄▇▃▂▇▇▃█▂ ▂▃▁▇▇▂▂▇▆ + ▆▇▁▄██ ▂▆▄▇▆▄▇▆ + ▅▆▅▇▂ ▆▁▅▂▃ ▃▃▂▃▆ + ▅█▅▁ ▆▇▇▂▅▅ ▃▃▃▆ + ▁█▁▂ ▂ ▅▂▆▅█ ▃▄▃█ + ▂▃ ▃▄ █▁▆ ▁ ▁ + ▁ ▁ ▆▇▄▄▄▄▄▇▇▇ ▃▁▆█▁▆ ▄▅ ▁ + ▁ ▁▃ ▁▂ ▂▆▁ ▃█▂▇▁▆ ▅█▅▅ + ▄ █▅ ▂▂███ █▂ █▇▂▆▄ ▅▂ ▆ + █▇ ▅▄▂ ▂▇█ ▅ + ▇▂ ▃▆▂ ▂▅▄▇█ ▆▂ + ▇▄▂ ▇█▇▇▅▁▁▄▅▄▇██ ▆▂ + ▇▇▇▄▇▂▂▂▂▆▂▄▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_22.txt b/codex-rs/tui/frames/hbars/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..38195cd38b3f40b85dbedf6f7eae7fab511a3948 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_22.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▇▄▄▇▄▇▆▂ + ▂▇▅▇▁▅▂▅▁▅▃▁▁▄▁▇▅▇▇▂ + ▆▄▇▄▁▃▇▇▂ ▂█▇▃▇▇▇▂▂▇▆ + ▄▇▁▇▇█ ▆▆▁▁▁▂▇▃ + ▃▁▁▅█ ▆▂▅▁▄▁▅▅▄█▁ + ▃▁▇▅▂ ▆▁▅▁▄▃▅▂ ▃▁▃▄▁ + ▁▇▇▃▅ ▁▇▁▃▇▅█ ▁▁▅▁ + ▁▁▄▁▂ ▁▃▁▁▃▃ ▁▃▁█▁ + ▁▁▆█▆ ▇▁▄▄▄▄▄▄▇▇▂▃▇▁▂ ▃▆ ▂▁▄█ + ▃▁▄ ▇▂▃▃▆ ▅▆█▁▅▅▁ ▃▇▄▂█▁▆▆ ▅▃▄ + ▃▃▃▂▁ █████▇▇█▂ ▂▅▇▇▂▅▅▅█▆▂ + ▃▇▃▂▃▆ ▂▇▆█▁▆█ + ██▇▄ ▇▇▂ ▂▅▇▆█▇▂▅ + █▅▇▇▆█▇▁▄▄▇▄▄▅▄▇███▆▇ + █▁█▁▂▄▂▂▂▆▄▂▅▄▇▂ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_23.txt b/codex-rs/tui/frames/hbars/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..a81cac3ef20902409abd836c9e9001716cb14a91 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_23.txt @@ -0,0 +1,17 @@ + + ▂▆▇▂▄▇▇▇▇▄▇▂▂ + ▂▄▁▇▁▃▂▆▂▄▄▆▁▂▁▇▁▇▂ + ▆█▇▁█▆▄▇▂ █▁▅▇▁▃▄▁▁▆ + ▅▄▁▇▄▅▂ ▂▄▄▃▁▁▄▃ + ▅▅▁▂▅█ ▆▅▆▆█▇▅▇▃▁▃ + ▆▃▁█▅▂ ▂▇▇▇██▅▇█▁▁▃▁▆ + ▂▁▁█▁ ▅▅▅▅ ▂▁▂ █ ▁█▇ + ▁▅▁ ▁ ▁█▁▆▂▃▆ ▁ ▇▅▁ + ▃█▃ ▁▇▁▄▄▄▄▄▄▄▄▁▂▇▁▂█▃▂ ▁ ▁▁▁ + ▁▃▃▂▁█▄█ ▅▂▃ ▃▁▂▃▆▁ ▃▂▁ + ▅█▁ ▁ ▂███████ █▂▅▄▄▁▂▅▃▅▂ + ▆▁▁▂▁▅ ▆▅▁▃▃▅▂ + ▃ ▁▆█▅▂▂ ▂▅█▄▇▅▇▅ + ▃▄▃▇▄▇▇▃▁▄▄▂▇▇█▆▂▇▇▂ + █▁ ▇▄▃▁▂▂▇▂▂▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_24.txt b/codex-rs/tui/frames/hbars/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..791f93b591448798822a4b89e47c93407eb5e867 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_24.txt @@ -0,0 +1,17 @@ + + ▂▁▂▄▇▇▇▄▄▆▂ + ▆█▂▅█▁▂▂▁▄▄▇▆▂█▇ + ▆██▄▃▅▇█▁ ▅▂▅▇▁▇▇▆ ▃ + ▆█▅▅▆▅▁▄▆▃▂▁▅▂ ▂▇▃▁▃▂█ + ▆ ▅▅▃▅▆ ▂██▅▃▂▁▂▂▃▁▆█ + ▁▆▃▇▃▂ ▇▆▆▅ ▅▁▄ ▅█▃ + ▁ ▁▁ ▆▅▂▆▅█▅▃▃▁▃▃▁▃ + ▁ ▃▁▁ ▁▆▄▅▃▂▁▁▂▅▅▂▁▁ + ▁▅ ▁▄▄▄▅▇▇▁▁▁▇▇▂▇▆▂▁ ▁ ▁▁ + ▂ ▅ ▇▁▅▆▆▄▄▆▆▁▅▃▃▇▇▁ ▁▇▁ + ▃ █▃▁▃▇██████▂ ▃▆▅▇▃▁▄▅▆▂ + ▃ ▃▆▇▃ ▁ █▅▄▅ + ▃▅▇▃▂▁▅ ▂▄█▂▅▅▂▇ + ▅▆▇▆▃▃█▁▁▅▄▄▄▁▃▂▅▂ + ▇▅▂▇▇▄▃▂▂▄▄▇▇ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_25.txt b/codex-rs/tui/frames/hbars/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..565fdb82ead06518468825504520ea17adc275b2 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_25.txt @@ -0,0 +1,17 @@ + + ▂▇▄▇▄▇▇▇▇▂ + ▆ ▇▆▇▂▂▄▃▁▇▁▁▆ + ▅▁▅▅▆▆█▅▁▇▃▄▃▁▁▁▂ + ▁▇▇▁ ▅ ▄▁ █▃▁▆▇▃▅ + ▆▂▂▃▆▅█▃▄▅▂▁█ ▁▅▅▃▅▇ + ▃▆▁▁▁▂▇▇▅▃▁▅ ▅▁▆▁▅▅▁▆ + ▁▅▁▇▁ ▁▂▂▅▃▂▅▁▃▅▂█▁▄▁ + ▁▁▁▅▁▅█ ▁▂▄▃▅▁▃▂▁▄▁▁▃ + ▃▇▃▁█▁▂▂▄▄▄▄▇▁▁▃▃▁▇▇▁▃ + ▅▆█▁▅▁▆▁▂▁▁▄▇▂▁▃▇▄▅▃▁ + ▃▁▇▅▃▁▂██████▇▅▁▃▁▅▁▂ + ▁▂█▁▄▇▄▃▆▆▇▅▂▅▆▅▅▁▅ + ▃▂▃▁▂▃▂▇▄▅▆▃▅▂▁▁▅ + ▃▅▄▃▃▂▇▄▁▇▇ ▇▁▇ + ▁▆█▇▃▇▆▂▇▁▇ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_26.txt b/codex-rs/tui/frames/hbars/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..e37d671dc4be8841c50001fd65ccf8023bea58cd --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_26.txt @@ -0,0 +1,17 @@ + + ▂▄▇▇▇█▇▆ + ▅▇▁▄▅▃▁▇▄▁▆ + ▅▇██▁▄▃▁▃██▃▆ + ▄▇█▆▁▁▁▂▁▁▂▇▅▃ + ▁▂▆▃▁▅▁█▇▃▅▄▁▃▄ + ▃ █▁▅▅▅█▇▂▃█▃▃▅ + ▁ ▇█▄▁▁▅▁▁▄▄█▇ + ▅▆ ▃▁▁▁▃▃▄▁▁▅▁▁ + ▁ ▅▃▇▄▇▁▇▄▄▅▃▁▁ + ▅▂▁▅▆▁▂▂▆▇▃▃▇▁ + ▁▂█▅▁▇█▃▆▅▂▇▃▁▁ + ▃▂▁▁▅▃ ▅▅█▁▁▂ + ▇ ▇▁▃ ▃▂▅▁▅▆ + ▁█▇▃▃█▇▂▇▇▄ + ▃▅▅▃▇▁▂▃▇ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_27.txt b/codex-rs/tui/frames/hbars/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..d3dbefa975441f58ab2219ce6ba3ee3f292632c9 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_27.txt @@ -0,0 +1,17 @@ + + ▄▇▇▇▇▆ + ▁▄▅▅▁▅▃ + ▄█▂ ▇▁▅█▁ + ▁▁▁▃▁▁▄▁▁ + ▁ ▂▁▂▆█▄ + ▁▂▅▂█▂▁▁▁▂ + ▄ █▁▁▂▇▁▁ + ▁ ▂▁▁▁▄█▁▁ + ▁▅ ▅ ▁▇▁▁▁▁ + █▄ ▅█▂▁▂▇▁▁ + ▁▆ ▂▃▇▇▇▅█ + ▁▁▇▇▁▁▂▅▁ + ▁▄▄▇▄▆▅▁▃ + ▃▂▂▁▃▄▅▅ + █▃█ ▃▃▅ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_28.txt b/codex-rs/tui/frames/hbars/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..0ae0f54e0b064fe11b612d8e2f0988f09f85f662 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_28.txt @@ -0,0 +1,17 @@ + + ▅▇▇▄ + ▁▇▂▁▂ + ▄ ▄▁▂ + ▁▆▂▇▁ + ▁ ▁▁ + ▁ ▆█▁ + ▃▁▁ ▂▁ + ▁▆██▁ + ▅▃ ▂▁ + ▁▁▅▅▁▁ + ▁ ▂▁ + ▁▄▇▄▁ + ▁▄▇▁▁ + ▇▂▂▅▁ + ▁ ▅▇▁ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_29.txt b/codex-rs/tui/frames/hbars/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..d333f278dce9f20187c6fae6a6e5d9fcb5a6a18a --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_29.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▆ + ▆▁▇▅ ▇▃ + ▃▆▁▁ ██▁ + ▁▁▁▁▃▃▆▅ + ▃ ▁█▁ ▁ + ▁▆▁▁▁ ▂▂ + ▁▃▃▁▁▁ ▃ + ▁▁▄▁▁ + ▁▁▁▇▁ ▂ + ▂▁▄▁▁ ▂ + ▇▁▁▅▁▃ ▄ + ▁▅▁▁▁ ▂▁ + ▁▁▁▁▂▁▂▁ + ▁▄▁▇▅█▁█ + ▁▁▇ ▅▆ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_3.txt b/codex-rs/tui/frames/hbars/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..5d0b07202ae96a91966b09d157b6ae27d43a7a8c --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_3.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▇▇▄▄▄▄▆▂ + ▂▇▆▅▃▁▁▇▁█▄▄▁▄▂▃▁▇▇▂ + ▆▄▅▇▄▁▇██▂ ▂█▁ ▇▇▇▅▃▁▆ + ▅▇▅▃▁▄▆▇▂ ▂▄▇ ▁▁▃ + █▅▇▁▅▃▅▁▁▇▃▂ ▃▃▃▁▁▂ + ▅▁▃▃▅ ▅▆▁▃▄▃▃ ▁ ▃▃▁ + ▄▇ ▁ █▇▃▁▂█▁▆ ▅▇▅▁▆ + ▆▆▁▆▁ ▆▆▁█▅▃▁ ▁▂▁▄▁ + ▆██▁ ▄▁▇▇▁▁▇▆▁▄▇▇▂▂▂▇▇▂▁▃█▄▁ + ▆▃▅▃▆ ▅▅▇▅▄▄▁ ▁▇▁▂▂▆ ▄▅▆▄▃▂▁▃▁▂ + █▃▁ ▁ █▁▁▇▆█ █▇▇▇███▇▇▆▅▅▅▆▅ + █▇▁ ▃▇ ▂ ▅▃▇▃▅▅ + ▄▃▅▅▇▃▆ ▆▇▄▅█▆▁█ + ▄▃▇▆▂▇▇▃▄▄▄▄▆▅▄▇▄▂▂▇█ + ▂▇▇▇▂▅▁▂▂▂▂▁▄▅▃█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_30.txt b/codex-rs/tui/frames/hbars/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..7ceb36d37ac9562ce7238aed4736d869d2656251 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_30.txt @@ -0,0 +1,17 @@ + + ▂▄█▇▇▇▆ + ▂▅▄▁▁▃▇█▄▃ + ██▃▁▆▃▃▁▇▆▃ + ▁▁▃▁▅▁▂▃▁▃ ▃▆ + ▄▇▁▁▂▃▁▆▁▆ ▁ + ▁▇▁▃▇ ▇▁▁▁▁▅▁ + ▃▅▁▁▁▃▂ ▃▅▃▁ ▄▂ + █▁ █▇▄▁▆▁█▁▆ ▂ + █▁▂█▃▁▄▁▃▅▁▆ ▂ + █▁▁▃▁▂▂▁▁ ▇▆ ▂ + ▃▁▁▄▃▂▇▁▁▁▇▁▁ + ▁▇▅▅▆▅▇▃▁▁█ ▁ + ▃▁▃▁▁▅▇▁▁▆▁▅ + ▆▃▆▇▄▃▅▁▆▅█ + ▃▆▅▁▃▂▂▄█ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_31.txt b/codex-rs/tui/frames/hbars/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..419be30ed96947979a00dcf8e98a18dd0bbab6fc --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_31.txt @@ -0,0 +1,17 @@ + + ▂▅▅▇▇▇▇▄▆ + ▂▇▇▄▇▆▂█▇▃█▇▆ + ▆▁▃▅▅▆▄▂▂▅█▁▂▇▃ + ▆▁▅▇▂▂▇▄▂▆▃▃▁▃▁▁▃ + ▃▁▁▁▁▇▆▇▃▂▆█▃▁▅▂▂ + ▄▁▃▂▁▂▁██▃▄▇▆▅▅▁▆█▁ + ▅▁▁ ▁▃▇▁▃▅▄▅▄▇▁▇▁▅▁ + ▃▃▆ ▁▁█▁▃ ▁▃██▁▁▃▅▁ + ▁▃▆▂▁█▃▁▁▁▅▇▁▁▁▂ ▁ + █▁▁▁▅▅▁▁▂▂▂▃▂▁▃▁▁ ▇ + ▃▇▂▅▇▁▆ ▅▇▇▇▂▅▁█▁▂ + ▁▁▁▃▃▃▆ ▂▄▇ ▁ + ▄▁▇▄▂ ▆ ▅ ▁▇▂▅▂ + ▅▁▃▁▅▂▁▂▆▁█▆▁▂ + ██▄▁▂▇▇▅▅▄▇ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_32.txt b/codex-rs/tui/frames/hbars/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..1234a419b0c71076d36d3b9325d7acffbd7bc6a1 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_32.txt @@ -0,0 +1,17 @@ + + ▆▇▇▇▇▄▄▁▆▂ + ▄▇▁▇▇▁▃▁▁▇▇▁▁▆ + ▅▃▇▄▃▆▇▃▅▆▂▇▁▇▃█▇▆ + █▅▅▁▂▆▂▄▆ ▆ ▆▂▃▁▇▆ ▆ + ▅▅▁▆▁▇▃█▇▁▄ █▂▆▁▃▅▃▇▃▆ + ▃▅▃▃▆▃▇▁▂▅▁▁▇▂ ▁▇▅ ▇ + ▇▁█▄▆▂█▃▁▁▆▆▁▆▄▂▅▂▅▅▁█▂ + ▁▁▁▆▂ █▂▁▁█▅█▁▄▆▂█▃▁██▂ + ▃▁▁▆▂ █▂▁█▂▂▁▇▇▇▁▁▁▄▁█▄▂ + ███▂▂▁▇▃█▃▂▂▂▂▂▁▁▇▁▄▁▆▂ + ▆▃▇▁▁▅ ▁▇██▇▇▇▃▄▅▅▅▅▅ + ▃▆▁▃▃▃ ▆▃▁▅█▁ + █▇▇▁▁▁▇▂ ▂▅▂▁▅ ▆ + ▃▆▃█▁▇▁▄▄▇▂▂▇█▂▅ + ▁▄▄▂▂▆▇▅▇▂██ + ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_33.txt b/codex-rs/tui/frames/hbars/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..780eb104ef3eda0a96984b45f4cd4718a38ffffd --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_33.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▇▄▇▄▁▅▂ + ▂▇▇▁▁▃▇▄▁▅▁▆▁▇▁▇▅ + ▂▅▃▁▇▇▇▆▄█▂ █▇▅▁▁▇█▆ + ▆▃▅▁▅▂▁▆▂ ▇▁▁▃▂▇ + ▂▅▁▅▂▁▁▅▃▃▃ ▁▇▃▃▃ + ▅▅▆ ▃▂▃▃▂▃▆ ▅▃▅▂▃ + ▁▁▇ ▃▆▇▁▂▁▃ ▁▇▁▁ + ▇▁▆▆ ▆██▃▅▄▆ ▃█▁▇ + ▁▁▁ ▁ ▆▂ ▅▇▆▆▆▄▄▄▇▁▁▇▅▂▁▃▁ + ▃▅▁██▅▅▆▅▂▁▁▂▂▂▂▂▁▁▇▃▃▃▁▁▅ + ▃▄▃▆ ▁▅▁▅ ▅ ████▇▇▇ ▁▁▆▅▃▂ + ▃▇▃▇▆█▆ ▅▅▄▅▄▅ + ▇▃█▇▆▇▄▆ ▂▇▁▆▅▇▃█ + ▃▁█▇▃▇▁▆▄▇▂█▄▆▃▇▁▇ + █▄▄▇▆▂▂▂▃▇▇▄▆█ + ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_34.txt b/codex-rs/tui/frames/hbars/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..4bf69e69eb4f7b8f84cd80cce65d211de035bd55 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_34.txt @@ -0,0 +1,17 @@ + + ▂▆▄▇▇▇▇▄▁▆▂ + ▂▇▇▇▅▃▄▁█▁▇▇▄▇▇▄▆▄▂ + ▂▅▅▆▁▇█▅▁▇▂ ▂▃▃▃▆█▇▇▆ + ▆▅▂▁▇▂▅▆▂ ▇▁▆▇▃▃ + ▅▅▁▃▆▅▁ ▁█ ▆ ▃▃▇▃▃ + ▆▅▃▁▂▅ ▃▃█▁▃▇▃ ▃▆▃▁▃ + ▁▇▃▁▂ █▃▂▇▁▂▃ ▁▄▁▂ + ▃▁▄█▅ ▁▇▂▁▅█▁ ▅ ▂▃ + ▆█▅▅█ ▅▃▅▅█▂▄▄▇▇▇▇▇▇▁▂▂█ ▁▃▂ + ▁▁▃▆▅▆▆▃█▅▅█▁▁▁▂▁▅▂▂▂▆▅▂▁▁▂▁▃ + ▅▂▃▆ ▁▃▁▇▃ █▇▇▂▂▂▂▃ ▆▅▆▃▁█ + ▅▆▃▄▄▃▂ ▅▄▅▁▅█ + ▃▇▂▁▇▃▄▆ ▂▇▄█▂▅▆▂ + ▆▃▅▁█▁▇▆▄▆▇▄▇▄▇▂▇▇▅█ + █▁▃▄▄▂▂▂▂▂▄▄▇▄▃ + ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_35.txt b/codex-rs/tui/frames/hbars/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..86dde2ad3418377dd6c1fbd5091b19dcdd879044 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_35.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▄▇▇▇▄▇▆▂ + ▆▇▄▇▅▁▇▇▇█▁▇▁▅█▁▇▄▂ + ▂▅▅▁▄▇▇▁▃▇█▂ ▂█▇▄▇▅█▁▇▆ + ▆▅▅▇▅▆▄▆▂ ▇▇▂▃▁▂ + ▅▅▁▁▅▅▃ █▃▇▆ ▁▃▇▁▆ + ▄▅▄▅▇▂ █▁▂█▅▅▇▂ ▁▃▃▁ + ▃ ▅▅ ▃▃ █▇▂▅ ▁▃▃▃ + ▁▁▁▅ ▅▃▃▄▅█ ▅ █▅ + ▅ ▆▂ ▆▁▁▅▄█▅▁▄▄▂▄▂▄▇▁▁ ▁▁▄▇ + ▁▆▃▄█ ▅▅▄█▇▇▅▁▄ ▃▆▂▂▂▂▅█▁▅█ ▁█ + ▅▂▃▆█▅▃▂▁▅▃▂ ▂█▃▃▃▃▇▃▃▇▅▅▂▆▁ + █▅▃▃▅▁ ▂▅▇ ▆▃ + ▃▄▂▃▄▆▅▆ ▆▄▅▂▇▇█ + █▃▂█▁▇▁▆▅▁▁▇▁▄▅▇█▂▅▄█ + █▃▁▄▄▂▂▅▂▂▇▇▇█▃ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_36.txt b/codex-rs/tui/frames/hbars/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..bccadcf7b782fdaa0857810e37637fb2ec3fc788 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_36.txt @@ -0,0 +1,17 @@ + + ▂▂▇▄▇▇▇▇▄▇▆▂ + ▂▄██▃▇▅▄▅▇▃▇▄▇▇█▇▇▇▂ + ▆▃█▃▆▇▇▆▇█▂▂ ▂█▇▁▇▇▁▆▇▁▆ + ▂▁█▇▇▂▂▂▂ █▇▁▃█▁ + ▆▅▂▄▃█▁▃▂▃▆▅ ▃▃▅▇ + ▅ ▆▅▂ ▇▂ █▁▅▆ █▃▃▇ + ▁ ▄ ▂ ▃ █▃▃ ▁▃▁▆ + ▁▁▃▁ ▂▃ ▂▅▅ ▃▁ ▁ + ▁██▁ ▅▅ ▆▇██▆▇▇▂▇▇▇▇▇▂ ▁▃▁▁ + ▂▇▁▃ ▆▁▂▂▅▁▅ ▂▁▂▂▂▂▂▂▂▁▁ ▃▅█▂ + █ ▇▁▃ ▇▂▇▅▃ █▇▇▃▃▃▃▃█ ▁▆█▅ + █▆█▃▄▅ ▆▅▁█▅ + ▃▅▁▁▇▁▆ ▆▇▇█▅▆▂ + ▇▆▂█▇▇▄▃▄▁▂▇▁▄█▆█▆▄▇█ + ▂▇▇▅▁▂▆▆▂▆▆▇▇▇▇█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_4.txt b/codex-rs/tui/frames/hbars/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..5867215a96d4d3f1e2be6ead0f6d2d318a9bc67f --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_4.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▇▄▄▇▄▇▆▂ + ▂▆█▇▇▁▅ ▁▃▄▅▇▄▁█▅▇▆▂ + ▆▇▄▁▁▁▇▇█▂ ▂▃▁▃▃▇▁▅▃▃▂ + ▅▅▁▆▁█▅▇▆ █▁▇▇▃▇▃ + ▅▁▅▂▁▄▁▃▄▃▁▆ ▃█▃▁▇▃ + ▅▁▁▃▁ █▂▁▁▅▇▃ █ ▃▄▁▆ + █▃▁▄▂ █▇▇▃▇█▁▆ ▆ █▄▁ + ▁▅▃ ▆▁▅▇▃▁▁ ▁▁▁ + ▂▁ ▁▂ ▄▁▅█▃▅▅▇▁▇▇▇▂▂▂▂▆▆ ▁▁▇ + ▁▃▅ ▁ ▆▅▅▅▆▂▁▂▄▃▁▆▅▂▆▅▅▃▄▁▅▅█▁ + ▂▁▄█▃▆▃▃▆▃▅ █▇▇▇▇▇▇▇█▃▆▁▁▁▂ + ▆▁▃█▁▂▂ ▆▃▄▅▇▇█ + ▃▃▆▅▃ ▆▂ ▂▅▃▆▇▄▁▅ + ▁▁▁▄▂▇▇▃▄▄▄▄▆▇▄▇▇▃▁▇▂ + ▇▃▃▁▅▁▂▂▂▂▂▄▆▇▂ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_5.txt b/codex-rs/tui/frames/hbars/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..d0cd750b8a7112eba06f36194abb7f3cd6416c57 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_5.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▄▇▄▄▄▇▆▂ + ▂▇█▇▅▇▅▁▁▇▁▄▇▄▂▁█▇▆ + ▇▂▅▃▇▁▇█▂ █▃▄▁▇▃ ▆▇▆ + ▆▁▅▁▅▁▅▄▆ ▆▃▁▁▁▁▂ + ▆▃▅▇▅▃▂▇▁▂▃▆ ▂ ▃▁▃▁▂ + ▁▁▁▁▅ ▃▂▁▃▄▅▁▂ ▇▅▁▁ + ▁▅▁ ▇ ▂▃▂▁▃█▁▆ ██▁▇▁▆ + ▁▅▁▂█ ▆▂▄▁▅▂▁ ▇▁▂▁ + ▁▆▁▃▇ ▅▂▁█▆▁▇▄▂▁▇▁▂▂▂▇▆▂█▁▅▁ + ▂▃▁▃▆▆▅▇▇█ ▁█ ▅▅▃▄▃▆▄▅▆▅▁▅▇▁▂ + ▃▆▁▁▆▁▆▃▃▅▇▂ █▇▇█████▆▁▄▁▃▅ + ▃▃▇▅▃▃ ▂ ▅▂▅▇▆▃ + ▃▅▇▃▁▄▇▂ ▂▄▇▆▇█▄▇ + ▃▁▇▁▆▇▇▃▄▁▄▂▆▇▄▇▃▁▄▇ + ▃▆▇▂▇█▂▂▂▆▂▆▇▇▂ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_6.txt b/codex-rs/tui/frames/hbars/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..2fde73afab15cf5e2c5c385323caf2b536f2e5a2 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_6.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▄▇▄▄▇▇▂▂ + ▆▃▄▅▇▁▄▇▇▁▇▇▄▂▆▇▇▂ + ▆▆▆▁▁▁▇█ ▂█▁▅▄▇▇▇▅▃▂ + ▅▆▁▇▁▇▂▄▆ ▃▇▁▁▁▁▆ + █▄▁▁▁▇▃▃▇▇▃ ▅▃▃▁▃▆ + ▄▆▅▁▅▂▃▇▄▁▃▂▁▆ ▅▄▃▅▁ + ▂ ▇▁▁ ▃▃▁▅▃▃ ▃▇▁▂▁ + ▁ ▁▁▁ ▂▁▄▁▅▁▁ ▁▇▃▁▁ + ▂▅▁▆▁ ▅█▁▁▇▄▅▇▂▁▇▁▂▂▇▇▂▅▃▅▁ + ▁▁▃▆▅▂▃▂▄▇ ▁▅▄▇▆▁▁▆▄▄▄▃▁▆▁▃ + ▅ ▁ ▅▁▁▁▄▅█ █▇████▇█▂▃▃▅▁ + ▆ ▄ ▅▆▂ ▆▅▂▅▅▁▂ + ▅▂▃▂▇▇▆ ▂▇▃▂▅▇▆▇ + ▂█▁▇▆█▇ ▄▁▂▂▆▇▇▇ ▅▄█ + ▃▆▄▇▇▆▂▂▂▂▂▆▇▇ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_7.txt b/codex-rs/tui/frames/hbars/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..f9b4ed92190a35d9b0364fb79e5a41f6399ae0d5 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_7.txt @@ -0,0 +1,17 @@ + + ▂▅▁▄▇▇▄▄▇▆▂ + ▆▇▂▇▃▁▇▇▇▄▄▃▄ ▂▇▆ + ▇ ▆▁▁▁▇▂ ▂▃▁▂ ▇▅▂▇▆ + ▃▃▁▁▁▁▄▇ ▃▁▃▇▁▅▆ + ▂█▁▇▁▁▃▁▅▃▆ ▅ ▇▁▁▃ + ▁ ▄▁▁█ ▂█▃▄▁▃ ▆▆▁▁▁ + ▂▃█▁ ▃▂▁▃▁▃▁ ▇▇▃▁▁ + ▂█▅▁▁ ▄▇▅▁▇▁▂ █ ▁▁▁ + ▅█▁▇ ▆█▂▅▁▃▅▁▁▄▄▂▁▁▄▄▇▃▁▁ + ▁▆█▁▁▄▅▁▁▅█▁▅▃▁▁▃▃▆▄▆▄▃▁▃▁ + ▃ ▄▃▅▇▁▇▂▅ ▇▇▇▇▇▇██▂▅▁▁ + █▂ ▅▂▅▂▂ ▆█▆▅▄▅█ + █▄▆▇▃▃▃▂ ▆▃▁▆▇▅▇█ + ▃▂█▃▁▇▃▄▁▂▂▂▇▇▁▇▇▅ + █▁▇█▁▅▁▂▂▆▂▄▇▇ + \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_8.txt b/codex-rs/tui/frames/hbars/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..44c448de8a3f17d7d6ede169c4dd33e648d35de1 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_8.txt @@ -0,0 +1,17 @@ + + ▂▂▇▂▄▇▇▄▇▆▂ + ▆█▇▅▁▁▁▇▄▄ ▁ █▇ + ▂▁▄▆▁▅▅▇ ▆█▁▅▁▇▃▇▃▆ + ▂▇▃▇▅▁▆▇▁ █ ▄▃▁▁▃ + █ ▅▁▅▆▃▁▅▃ ▅█▃▁▁▆ + ▁█▆▁▃▃▃▂▃▁▂▇ ▃▇▁▁▁ + ▁▇▁▁▁ █▂▄▃▁▂▁ ▁▃▁▅▁▆ + ▃▆▁▁▅ ▁▇▃▅▁▆▇▅ ▅▂▅▁▁ + ▃ ▃▁▁▂▃ ▅▇▄▁▁▅▇▆▇▆▆▃▁▁▁ + ▃▇▅▄▂▁█▆▁▆▁▁▄▁▄▂▂▁▁▄█ + ▅▅▃▃▁▃▁▁▁▅█▃▇▃▇█▃▇█▅▃▁ + █▆█▁▃▁▂▂ ▅▆▅▅▅█ + █▃▂▇▃▃▃▂ ▂▅▃▂▁▇▅▇ + ▅▅█▁▆▇ ▄▄▇▇▄▅▄▆▁▂ + ▁▁▂▇▇▃▁▂▆▇▅▄▂ + ▂▂ \ No newline at end of file diff --git a/codex-rs/tui/frames/hbars/frame_9.txt b/codex-rs/tui/frames/hbars/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..a18a8a231c353d57f92e88b70ec32ea4f6758065 --- /dev/null +++ b/codex-rs/tui/frames/hbars/frame_9.txt @@ -0,0 +1,17 @@ + + ▅▄▃▇▅▇▇▄▆ + ▅▇▂▅▁▁▇█▄ ▂▆▃▂ + ▅ ▆▁▁▅▇▃▃▄▅ ▃▂▁▆ + █ ▁▁▅▄▄▂ ▂▃▆▁▃▃▁▇▆ + ▅▇█▄▃▇█▁▇▆ ▇▅█▇▅▇▁▁ + ▁ ▁▁▁█▃▁▃▄▂▂ ▁█▁▇▁▁▁ + ▂ ▁▇▃▁▇▇▁▃▃▆█▅▆▅ ▂▁▁ + ▁ ▁▅▂▆▃▁▁▁▂▅▄▃▂▁ ▁▁ + █▆▁█ ▅▁█▁▁▁▁▇▁▁▆▁▁▂ + ▁▂▁▁▁▂▆▁▃▁▂▁▁▁▁▂▂▁▁▁ + █ ▂▃▅▃▃▁▅█▇▇▇▇▁▁█▅▃▁ + ▃▁▁▁▃▆▂ ▆▅ ▃▅▁▂ + ▃▇▃▁▃▃ ▅▄▂▄▁▅▇ + ▃▆█▃▂▁▇▅▇▄▄▄▇▁▂ + █▆▂ ▇▃▃▁▄▇▅█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_1.txt b/codex-rs/tui/frames/openai/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..1019a11c9581b7a782c76c854e063f29d71196d9 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_1.txt @@ -0,0 +1,17 @@ + + aeaenppnnppa + anpeonpepnniina aopa + pioipoooaa aooaoiniiip + noanooppa eoinip + naneoainann oeinnp + io pa ioeniip oeniip + paopo onioeoia iei + iiaia peeinio oai + inioa niianioeippppppapp no i + aino anpa eo ioiaaaeepppiepepi + naaoa anpeea aaoooo aooepnae + oap op a poeoai + anpanpa anapiapo + aopna opennnnenopapio + aoooiiiaaapanpoo + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_10.txt b/codex-rs/tui/frames/openai/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..942f59e944f48d93dfd9e6e099c7048a09e771e0 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_10.txt @@ -0,0 +1,17 @@ + + apooonppa + ooapopnieaop + aapiieiipipnpp + iaaeninaenpoonnp + e niioia opeaeie + ia oioieen aaoi + n ioooiiiio iep + o iinpepiipaaenii + o in nniniipnpnii + i pinpiaeaoaaaioa + p oiiiiioo nponia + naopnpoo aioopee + io iiiiopepeiea + naooeipna eeo + op aonapno + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_11.txt b/codex-rs/tui/frames/openai/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..ef0aff76e0f2bb97ff18885032ccea6a3292e817 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_11.txt @@ -0,0 +1,17 @@ + + pooooppa + eo niiinnp + eoaaiinnoenp + iaaiinnpanee + aoennipnonaiip + ipaiipanniaeii + oo piiiiiiainn + i iieiine ioi + e oieiioepiai + oo aienpoeiaia + oap iiiineinni + napeiaoeoonia + a oinnaaino + np oiipaeoa + na oopapa + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_12.txt b/codex-rs/tui/frames/openai/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..8940e05bd671db36d4af275632d0e9ba63411804 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_12.txt @@ -0,0 +1,17 @@ + + pooope + pnaaeipn + o ienpp + epiiinnoi + i oiiiieei + ioeeoaoaio + i ieniin + i epniio + op ieiipii + i eionoa + ie ionooe + p oi api + e ooiponi + oaeopinia + n oinee + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_13.txt b/codex-rs/tui/frames/openai/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..c73afab740d616be60a2a3362be1fa8261a262a1 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_13.txt @@ -0,0 +1,17 @@ + + eooop + iaaii + iaaio + iooia + o ii + oepii + p ii + p oi + ep aie + io pni + oaee ia + iaaai + ippia + oaain + p eon + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_14.txt b/codex-rs/tui/frames/openai/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..8a273a1666ac29ca696ecb42f00d661c2f80e56b --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_14.txt @@ -0,0 +1,17 @@ + + pooon + peaaen + iaiie i + iieioaip + iiin i + ioii oi + iiii e o + iinia n + iiii o + oaee ne + nioopepi + ioiiaaai + e oinnna + ineoaae + ao one + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_15.txt b/codex-rs/tui/frames/openai/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..5a0e8f1b549fc6d1a18aac3f3a6b85f02c01ed73 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_15.txt @@ -0,0 +1,17 @@ + + ppoooia + apniiaeop + eeionniooi + iea eepeaai + iinonniieoai + niipiieei o + ii iiiiiipi + iieiipiii opi + iipppoiiiaeei + iaineinioe ei + ieiiiiai a n + iooeaiinp n + oinp eeea na + iaiinnepne + naoaaaae + aa \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_16.txt b/codex-rs/tui/frames/openai/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..06c519f602890cbc36f6f2ff1c483bb431ebeb24 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_16.txt @@ -0,0 +1,17 @@ + + anpooopia + eaiioiininn + ieeonennii on + eee noaniniiooi + iinaeooniioeian + iiioio inn i iei + nni eapiiiieoop i + ien iaiieiiion ai + nniiipieaiioop e + ooaapnnnoieai pai + iiipooeoninoneiia + iio ia aeep e + nio i epeapi + niioaoieepai + oeeaneaano + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_17.txt b/codex-rs/tui/frames/openai/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..0bd4ef6dfc528e9a96d679108b77d721c7bba2a7 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_17.txt @@ -0,0 +1,17 @@ + + pnpppnnipa + anooiiionoipoap + poneannappoiiiaon + eieaeioaa oinniiinn + iea i ioennnee p + anopea peei epneiee + o nei peeia ooiieni + poni eeiieipiinno + peoennnpinainaeniii i + ioanaaieoneiin npopn + nnneooooooonaioeen no + niiao aeiia n + ann ona popeaae + p iopnnpaanoeio + apneappioapo + a \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_18.txt b/codex-rs/tui/frames/openai/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..de59f344efeec08b3b97b3ee446eee81e384aaa4 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_18.txt @@ -0,0 +1,17 @@ + + annpnnpnipa + apoanpppiaapopnna + aeeiooonopoeaopeen o + piioepaoo onaaeninnnnnp + anee ei aopaneieiannnna + eni ppieaaaepopiopeaeaei + iinee peoeioaeooininp + onpii anoiina inio + noo pnnnnnnpiipioenp ioia + anniono iieaanaiiianian p + ieinaoooooooa naaeieoeapa + nn pnp aaooeoao + naopaae aoaaenpa + oieooppnnnaooaeapo + oiiaeaapeponpo + a \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_19.txt b/codex-rs/tui/frames/openai/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..ade5662359321fc6d6235a1e43a7f8b71c17276d --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_19.txt @@ -0,0 +1,17 @@ + + aannpipnniea + apiiinennnaoapiopp + ponneoipoa aoepaonn + eniopaa annppaiop + iieaea piaoeoin inp + ineoo aioie ee oeoi + aniio eoaeippa eeepi + iiii inoniop i iii + ioinpnnnnnniiiiponoin n oii + anepinoeaopnpap aa enanpeeia + onoepioooooooo anaeiee eeo + nnaoan aeeaeeo + o aniiop pnneeina + ooeioeipaanneoo noio + oieaepoapeaopa + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_2.txt b/codex-rs/tui/frames/openai/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..be49360bbf5d6f9cb5fcfb56d45c9a1b4b1a39a9 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_2.txt @@ -0,0 +1,17 @@ + + aeaenpnnnppa + appeonpiiiniinaoiopa + pioinooioaa aoonoeiinip + aieaeooppa ppnieip + paoeoainanna apinip + oepoa ionpenp nnioi + piooi aeiniaip eoioi + io na pioiino ioeei + oooni niaaaioeipppppppppiaiio + anoi eiio eo innaaaaeeaaipe i + nnn o oneeio aoooooooooipoii + onaeep a ppnepo + enaaipp pnap aio + aianaaoopennnnenoaaiio + aopopiiaaaaineoo + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_20.txt b/codex-rs/tui/frames/openai/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..6eaf358e88da1f9666086c9477af863d992e5f47 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_20.txt @@ -0,0 +1,17 @@ + + aapniinpnpaa + pnnonpapnnniiaaonpp + ppoapooioo aoaonpoipa + aioaoio appineonn + ai pio ae aiiaoniin + eapio poonioe oniin + ninpi po epoa pnie + iaaea ooneiop iioi + ieiinpnnnnnnnnaponaanno e in + oiaiao eaaaaaain onnoinn pe ii + npanoooooooooo npniipi en + o aoep aeoeie + naaopia pnoanoo + appeoipannpeoioapeoo + aopia naaaaiooo + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_21.txt b/codex-rs/tui/frames/openai/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..5f317f375c5d74d59b32ac4873148a5bcf5d6484 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_21.txt @@ -0,0 +1,17 @@ + + aapnpnnpnppaa + ppoaaaapinipnaapopa + noaapoaoa aaiopaaop + poinoo apnopnop + epeoa piean nnaap + eoei pooaee nnap + ioia a eapeo nnno + an nn oip i i + i i ppnnnnnppp aipoip ne i + i in ia api aoaoip eoee + n oe aaooo oa ooapn ea p + op ena aoo e + oa apa aenoo pa + ona ooopeiinenooo pa + oppnpaaaapanpoa + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_22.txt b/codex-rs/tui/frames/openai/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..74b75b91135d53b04e264d6e02b6f1131d09879b --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_22.txt @@ -0,0 +1,17 @@ + + appnpnnpnppa + apeoieaeieniinipeopa + pnoninooa aoonoopaapp + noiooo ppiiiaon + niieo paeinieenoi + nioea pieinaea ninni + ipone ipinoeo iiei + iinia iniian iaioi + iipop pinnnnnnppanoia np aino + ain oannp epoieei nonaoipp enn + nnnai ooooooooa aeopaeeeopa + nonanp aopoipo + oopn ppa aeopooae + oeoppooinnpnnenoooopo + oioianaaapnaenoa + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_23.txt b/codex-rs/tui/frames/openai/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..35e7fe2210d21d3e446d16a2491ca430a5ace8fe --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_23.txt @@ -0,0 +1,17 @@ + + appanpoppnpaa + anipinapannpiaioipa + popiopnoa oiepinniip + enionea annniinn + eeiaeo peppooeonin + pnioea aoooooeooiinip + aiioi eeee aia o ioo + iei i ioipanp i oei + aon ipinnnnnnnniaoiaona i iii + innaiono ean nianpi nai + eoi i aooooooo oaenniaeaea + piiaie peinaea + n ipoeaa aeonoepe + nnnpnopninnappopapoa + oi onniaapaaooa + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_24.txt b/codex-rs/tui/frames/openai/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..a74ea1f0bb7dba04acec648d774b1a4a50bd1f9f --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_24.txt @@ -0,0 +1,17 @@ + + aianpponnpa + poaeoiaainnppaop + poonnepoi eaeoioop n + poeepeinpnaiea aoninao + p eenep aooenaiaanipo + ipnpna oppe ein eon + i ii peapeoeaninaia + i nii ipnenaiiaeeaii + ie innneppiiippaopai i ii + a e oieppnnppieanpoi ioi + n oninoooooooa npeoninepa + n npon i oene + neonaie anoaeeao + epopnaoiiennniaaea + oeaopnnaannpo + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_25.txt b/codex-rs/tui/frames/openai/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..c2c5b30b296ddd162a33d69ffd81a4d4c685bf80 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_25.txt @@ -0,0 +1,17 @@ + + apnonppppa + p opoaannioiip + eieeppoeipnnniiia + ipoi e ni onipone + paanpeoaneaio ieeneo + npiiiaopeaie eipieeip + ieioi iaaenaeineaoini + iiieieo ianneinainiin + apnioiaannnnpiinnipoin + epoieipiaiinoainoneni + nioeniaoooooopeinieia + iaoinpnnppoeaepeeie + aaaianaonepaeaiie + nennnaonioo oio + ipoonppapio + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_26.txt b/codex-rs/tui/frames/openai/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..09a947d35d6048501ab2ad88f05ee67a74733db0 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_26.txt @@ -0,0 +1,17 @@ + + anoooopp + eoinenipnip + eoooinninoonp + noopiiiaiiapea + iapnieiooneninn + n oieeeooanonne + i ooniieiinnop + ep niiiaaniieii + i enonpipnnenii + eaiepiaaponnoi + iaoeioonpeapnii + naiien eeoiia + o oin aaeiep + ioonnooaoon + neeaoiano + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_27.txt b/codex-rs/tui/frames/openai/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..b3fef11ac8c2dddd2d20a15741994a9dd82eca1b --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_27.txt @@ -0,0 +1,17 @@ + + nooopp + ineeien + noa oieoi + iiiaiinii + i aiapon + iaeaoaiiia + n oiiaoii + i aiiinoii + ie e ipiiii + on eoaiaoii + ip aaoopeo + iippiiaei + innpnpeia + naainnee + ono ane + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_28.txt b/codex-rs/tui/frames/openai/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..11fdcec5207721554fb1c53a282815287d2fb339 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_28.txt @@ -0,0 +1,17 @@ + + eoon + ipaia + n nia + ipaoi + i ii + i poi + aii ai + ipooi + en ai + iieeii + i ai + inpni + inoii + oaaei + i epi + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_29.txt b/codex-rs/tui/frames/openai/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..2dc6c667532d922ea57e1601cbbbd9a748c2f7e3 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_29.txt @@ -0,0 +1,17 @@ + + poooop + pioe on + apii ooi + iiiianpe + n ioi i + ipiii aa + inniii a + iinii + iiioi a + ainii a + oiieia n + ieiii ai + iiiiaiai + inioeoio + iio ep + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_3.txt b/codex-rs/tui/frames/openai/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..9026d59a43073cab1ef51e96113a6cdf146a9fae --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_3.txt @@ -0,0 +1,17 @@ + + aennppnnnnpa + appeniipionninaaiopa + pneonioooa aoi oopeaip + epeninppa ano iin + oeoieaeiiona naniia + einne epinnnn i nni + no i ooniaoip eoeip + ppipi ppioeni iaini + pooi niopiiopinppaaappaiaoni + pnenp eeoenni ioiaap nepnnainia + oai i oiippo ooooooooopeeepe + ooi np a eapaee + naeeonp ppneopio + nappaooannnnpenonaapo + aopoaeiaaaaineao + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_30.txt b/codex-rs/tui/frames/openai/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..73b4906d0ecaf5f6d5c5b75468975c157bf91d59 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_30.txt @@ -0,0 +1,17 @@ + + anooopp + aeniinoonn + ooaipnnippn + iinieianin np + noiianipip i + ioiap oiiiiei + neiiina neni na + oi opnipioip a + oiaoninineip a + oiiniaaii pp a + niinnaoiiipii + ioeepeoniio i + niniieoiipie + pappnaeipeo + npeiaaano + aaaa \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_31.txt b/codex-rs/tui/frames/openai/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..cc71fce920002736eaea6c33b717f2c9fef2d890 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_31.txt @@ -0,0 +1,17 @@ + + aeeopopnp + aponppaopnoop + pineepnaaeoiaon + piepaaonapnniniin + aiiiipponaponieaa + niaaiaioonnopeeipoi + eii iaoinenenoioiei + nnp iioin iaooiinei + inpaioaiiiepiiia i + oiiieeiiaaanainii o + aoaepip eoooaeioia + iiinnnp ano i + niona p e ioaea + einieaiapiopia + ooniapoeeno + aaaa \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_32.txt b/codex-rs/tui/frames/openai/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..c0d6573da78efc4fe2107a73ba8615f91c74a6ef --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_32.txt @@ -0,0 +1,17 @@ + + pppppnnipa + noiooiniiooiip + eaonappaepaoionoop + oeeiapanp p panipp p + eeipionooin oapinenonp + nennpnoiaeiipa ipe o + oionpaoniippipnaeaeeioa + iiipa oaiioeoinpaoniooa + niipa oaioaaipppiiiniona + oooaaiononaaaaaiioinipa + pnoiie iooooooaneeeee + npiann pnieoi + oooiiipa aeaie p + npnoipinnoaapoae + innaappeoaoo + aaa \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_33.txt b/codex-rs/tui/frames/openai/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..56ef96d36a802bc375a92b109795dff9bb7b5a78 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_33.txt @@ -0,0 +1,17 @@ + + appnpnpniea + apoiinonieipioioe + aenipoppnoa ooeiipop + pneieaipa oiinao + aeieaiienan ipnna + eep nannanp enean + iip npoiain ioii + oipp poonenp noio + iii i pa eopppnnnpiipeaini + aeiooeepeaiiaaaaaiioaaaiie + nnnp ieie e ooooooo iipeaa + npnopop eenene + onooponp apipeono + nioonpipnpaonpnoio + onnppaaaaponpo + aaaa \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_34.txt b/codex-rs/tui/frames/openai/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..b6e87c62f1cf041a0ffe6d41989f6135a02d6499 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_34.txt @@ -0,0 +1,17 @@ + + apnppppnipa + apopennioiponoonpna + aeepiooeioa aannpooop + peaioaepa oiponn + eeinpei io p nnona + peniae nnoinon npnin + ioaia onaoiaa inia + ninoe ioaieoi e an + poeeo eneeoannppppppiaao iaa + iinpeppaoeeoiiiaieaaapeaiiain + eanp inioa oooaaaaa pepnio + epnnnna eneieo + npaipnnp apnoaepa + pneioippnppnonoapoeo + oinnnaaaaannona + aaa \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_35.txt b/codex-rs/tui/frames/openai/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..899d6766b791ab194a118587416135a232e2dc7b --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_35.txt @@ -0,0 +1,17 @@ + + appnnpppnppa + ponoeioopoioieoiona + aeeinpoiaooa aoonoeoiop + peepepnpa opania + eeiieen onop inoip + nenepa oiaoeeoa inni + n ee nn ooae iann + iiie ennneo e oe + e pa piienoeinnananpii iino + ipano eenoopein npaaaaeoieo io + eanpoenaieaa aoaaaaoaaoeeapi + oeanei aeo pa + nnaanpep pneaooo + oaaoioipeiipineooaeno + oainnaaeaappooa + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_36.txt b/codex-rs/tui/frames/openai/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..9a23d2ddd6dde2c735e8e9d4cd4bad3877dacbd6 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_36.txt @@ -0,0 +1,17 @@ + + aapnppppnppa + anoonpenepnpnppooopa + pnonppopooaa aooiopipoip + aioopaaaa ooinoi + peannoinanpe aneo + e pea oa oiep onao + i n a n onn iaip + iini an aee ni i + iooi ee pooopppapppppa inii + aoin piaaeie aiaaaaaaaii neoa + o oin papea oooaaaaao ipoe + oponne peioe + neiipip pppoepa + opaooonaniapinopopnpo + aopeiappappppooo + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_4.txt b/codex-rs/tui/frames/openai/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..0c76cc5ce83ec54e675f54f680f451c1836ffdff --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_4.txt @@ -0,0 +1,17 @@ + + aennpnnpnppa + apopoie inneonioeopa + poniiipooa aainaoienna + eeipioepp oioonon + eieaininnaip nonion + eiiai oaiieon o nnip + onina ooonpoip p oni + ien pieoaii iii + ai ia nieoaeepipppaaaapp iio + ine i peeepaiannipeapeeanieeoi + ainonpnnpne oooooooooapiiia + pinoiaa paneooo + nnpea pa aeaponie + iiinaoonnnnnppnopnioa + onnieiaaaaanpoa + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_5.txt b/codex-rs/tui/frames/openai/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..2b06cade095a6eeaade967fac35b038159d2e7fa --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_5.txt @@ -0,0 +1,17 @@ + + aennnpnnnppa + apopeoeiipinpnaiopp + oaenpiooa oanion pop + pieieienp paiiiia + pneoeaapianp a ninia + iiiie nainneia peii + iei p anainoip ooipip + ieiao panieai piai + ipiao eaiopionaipiaaappaoiei + aninppepoo io eennapnepeieoia + npiipipnnepa oooooooopinine + nnpenn a eaeopa + aeoninpa anppoono + aioipopnninappnoaino + apoaooaaapappoa + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_6.txt b/codex-rs/tui/frames/openai/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..2ca8bb0bc79cad1ea1f61a8ba7387f2fb2677bbf --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_6.txt @@ -0,0 +1,17 @@ + + aennnpnnppaa + paneoinopippnapopa + pppiiioo aoienopoena + epioioanp npiiiip + oniiionnpon enninp + npeieaapninaip ennei + a pii aniean apiai + i iii ainieii ipnii + aeipi eoiionepaipiaappaenei + iinpeaaanp ienopiipnnnnipin + e i eiiineo ooooooooannei + p n epa peaeeia + eanaoop apaaeopp + aoiopop niaappoo eno + apnoppaaaaappo + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_7.txt b/codex-rs/tui/frames/openai/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..f66ddaf5a652dc224d6f1824cd54437501601ba3 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_7.txt @@ -0,0 +1,17 @@ + + aeinopnnppa + poapnipopnnnn aop + o piiioa aaia oeaop + nniiiinp ainoiep + aoioiiaienp e oiin + i niio aonnin ppiii + anoi aainini ooaii + aoeii npeioia o iii + eoio poaeineiinnaiinnonii + ipoiineiieoieniinnpnpnnini + n naeoioae ooooooooaeii + oa eaeaa popeneo + onppnnna paipoeoo + naonionniaaapoiope + oiooieiaapanoo + \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_8.txt b/codex-rs/tui/frames/openai/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..e54163d2c8a3535f311ea38a63c861e814ca9ca7 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_8.txt @@ -0,0 +1,17 @@ + + aapanppnppa + pooeiiionn i op + ainpieeo poieionpap + aonoeippi o naiin + o eiepnien eoniip + iopinaaaniao npiii + ioiii oanniai iaieip + npiie ipneipoe eaeii + n niiaa eoniiepppppniii + noenaiopipiininaaiino + eenniniiieoaoaoonooeni + opoiniaa epeeeo + onaonnna aenaipeo + eeoipo nnponenpia + iiaooniappena + aa \ No newline at end of file diff --git a/codex-rs/tui/frames/openai/frame_9.txt b/codex-rs/tui/frames/openai/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..a339de111841fee1cdda4e90523f2c1a4bf471b0 --- /dev/null +++ b/codex-rs/tui/frames/openai/frame_9.txt @@ -0,0 +1,17 @@ + + enaoeppnp + eoaeiioon apna + e piieoaane naip + o iienna aapinaipp + eoonnooiop oeopepii + i iiioainnaa ioioiii + a ioniooinnpoepe aii + i ieapniiiaennai ii + opio eioiiiipiipiia + iaiiiapiaiaiiiiaaiii + o aaennieoooooiioeni + niiinpa pe aeia + npninn enanieo + aponaioepnnnoia + opa onninpeo + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_1.txt b/codex-rs/tui/frames/shapes/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..244e2470b4f121f8eddae3c48c1b8fb3c1bb0c26 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_1.txt @@ -0,0 +1,17 @@ + + ◆△◆△●□□●●□▲◆ + ◆●▲△□○□△□○●◇◇●◆ ◆■□◆ + ▲◇□◇□□□■○◆ ◆■□◆■◇●◇◇◇□ + ●□◆○□■▲▲◆ △□◇●◇▲ + ○○●△■○◇○◆○○ ■△◇○○▲ + ◇□ □◆ ◇□△●◇◇▲ ■△○◇◇▲ + □○■▲□ ■○◇□△■◇◆ ◇△◇ + ◇◇◆◇◆ ▲△△◇●◇□ ■◆◇ + ◇●◇■◆ ●◇◇○○◇■△◇□□□□□□◆□▲ ●■ ◇ + ◆◇●□ ◆●□◆ △□ ◇■◇◆◆◆△△▲▲▲◇△▲△▲◇ + ○○◆■○ ○○▲△△◆ ◆○□■■□ ○□■△▲●◆△ + □○▲ ■▲ ◆ ▲■△□◆◇ + ○○▲◆○□◆ ◆●◆□◇◆□■ + ○□▲○◆ □□△●●●●△●□□◆▲◇□ + ◆□■□◇◇◇◆◆◆▲◆●□□■ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_10.txt b/codex-rs/tui/frames/shapes/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..f306dffc087e2a87ffd408b59b56eafa7af364fe --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_10.txt @@ -0,0 +1,17 @@ + + ◆□□□□○□□◆ + □■◆□□□○◇△◆□▲ + ○◆▲◇◇△◇◇▲◇□○▲▲ + ◇◆◆△○◇●◆△○▲■■○○▲ + △ ●◇◇■◇○ ■▲△◆△◇△ + ◇◆ ■◇□◇△△○ ◆◆■◇ + ○ ◇□■□◇◇◇◇□ ◇△▲ + ■ ◇◇○□△□◇◇▲◆◆△○◇◇ + ■ ◇○ ○○◇●◇◇□○□●◇◇ + ◇ ▲◇○▲◇◆△◆□◆◆◆◇□◆ + ▲ ■◇◇◇◇◇■■ ○▲■○◇◆ + ○◆■▲○▲□■ ◆◇■■▲△△ + ◇■ ◇◇◇◇□▲△▲△◇△◆ + ●◆□□△◇□●◆ △△■ + □▲ ◆□○◆▲●□ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_11.txt b/codex-rs/tui/frames/shapes/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..dcf944902b3488c795f21e9df0a21a3d0c8abe40 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_11.txt @@ -0,0 +1,17 @@ + + ▲□□□□□□◆ + △■ ●◇◇◇○○▲ + △■◆◆◇◇●○□△○▲ + ◇◆◆◇◇●●▲◆●△△ + ◆■△●○◇□○■●◆◇◇▲ + ◇□◆◇◇□◆●●◇◆△◇◇ + □□ ▲◇◇◇◇◇◇◆◇●○ + ◇ ◇◇△◇◇○△ ◇■◇ + △ ■◇△◇◇□△□◇◆◇ + □□ ◆◇△●▲■△◇◆◇○ + ■◆▲ ◇◇◇◇●△◇○○◇ + ○◆▲△◇◆□△□□●◇◆ + ◆ □◇○○○◆◇●■ + ○□ □◇◇▲◆△□◆ + ○◆ ■□□◆□◆ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_12.txt b/codex-rs/tui/frames/shapes/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..d8d1fbf334f341eaf8527ecdac4bdc085a21b84c --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_12.txt @@ -0,0 +1,17 @@ + + □□□□□△ + ▲●◆◆△◇▲○ + ■ ◇△○□▲ + △□◇◇◇●●■◇ + ◇ ■◇◇◇◇△△◇ + ◇■△△□○■◆◇■ + ◇ ◇△○◇◇○ + ◇ △□○◇◇■ + □▲ ◇△◇◇□◇◇ + ◇ △◇□●□◆ + ◇△ ◇■●□□△ + ▲ □◇ ◆▲◇ + △ □□◇▲□○◇ + ■○△■▲◇○◇◆ + ○ ■◇○△△ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_13.txt b/codex-rs/tui/frames/shapes/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..1387fc9b9124956fd2201b42476916fdf42011d5 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_13.txt @@ -0,0 +1,17 @@ + + △□□□▲ + ◇◆◆◇◇ + ◇◆◆◇■ + ◇□□◇◆ + □ ◇◇ + ■△▲◇◇ + ▲ ◇◇ + □ ■◇ + △□ ◆◇△ + ◇■ □●◇ + ■◆△△ ◇◆ + ◇◆◆◆◇ + ◇□▲◇◆ + □◆◆◇● + ▲ △■● + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_14.txt b/codex-rs/tui/frames/shapes/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..70a5070ba9b2c7d9125e514bf6c8f8aae370b98f --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_14.txt @@ -0,0 +1,17 @@ + + □□□□● + ▲△◆◆△○ + ◇◆◇◇△ ◇ + ◇◇△◇■○◇▲ + ◇◇◇○ ◇ + ◇□◇◇ ■◇ + ◇◇◇◇ △ □ + ◇◇○◇◆ ○ + ◇◇◇◇ ■ + ■○△△ ●△ + ○◇■■▲△▲◇ + ◇□◇◇◆◆◆◇ + △ ■◇●●●◆ + ◇○△□◆◆△ + ◆□ ■●△ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_15.txt b/codex-rs/tui/frames/shapes/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..584e0e043a9b3c0616ddad2529d00c78c59ecd85 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_15.txt @@ -0,0 +1,17 @@ + + □□□□□◇◆ + ◆▲●◇◇○△□▲ + △△◇□●○◇■□◇ + ◇△◆ △△▲△◆◆◇ + ◇◇●■●○◇◇△■○◇ + ○◇◇▲◇◇△△◇ ■ + ◇◇ ◇◇◇◇◇◇▲◇ + ◇◇△◇◇□◇◇◇ ■▲◇ + ◇◇□▲▲□◇◇◇◆△△◇ + ◇◆◇●△◇○◇□△ △◇ + ◇△◇◇◇◇◆◇ ◆ ● + ◇□□△○◇◇○▲ ● + ■◇○▲ △△△◆ ●◆ + ◇○◇◇○○△□○△ + ○○□○◆◆◆△ + ◆◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_16.txt b/codex-rs/tui/frames/shapes/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..af6c836855342f4d08039a35938aaae9946eef48 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_16.txt @@ -0,0 +1,17 @@ + + ◆●□■□□□◇◆ + △○◇◇□◇◇○◇●○ + ◇△△■○△●○◇◇ ■○ + △△△ ●□◆○◇○◇◇□□◇ + ◇◇●◆△□■●◇◇□△◇◆○ + ◇◇◇□◇■ ◇○● ◇ ◇△◇ + ○○◇ △◆▲◇◇◇◇△□■▲ ◇ + ◇△● ◇◆◇◇△◇◇◇■● ◆◇ + ○○◇◇◇□◇△○◇◇■□□ △ + □■◆◆▲●●○□◇△◆◇ ▲◆◇ + ◇◇◇□■■△□○◇●■●△◇◇◆ + ◇◇□ ◇◆ ◆△△▲ △ + ○◇□ ◇ △▲△◆▲◇ + ○◇◇■◆□◇△△□◆◇ + ■△△◆●△◆◆●□ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_17.txt b/codex-rs/tui/frames/shapes/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..4a158cf6094a6a0cab719980014b6ec15a82e110 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_17.txt @@ -0,0 +1,17 @@ + + ▲●□□□●●◇▲◆ + ◆●□□◇◇◇□●□◇▲■○▲ + ▲□○△◆●●◆□▲■◇◇◇◆■○ + △◇△◆△◇■◆◆ ■◇●○◇◇◇●○ + ◇△◆ ◇ ◇■△○●○△△ ▲ + ◆●□▲△◆ ▲△△◇ △▲●△◇△△ + □ ●△◇ ▲△△◇◆ □■◇◇△●◇ + ▲■●◇ △△◇◇△◇▲◇◇●●□ + ▲△□△○●●□◇○◆◇○○△○◇◇◇ ◇ + ◇■◆●◆◆◇△□○△◇◇○ ○□■□○ + ○○○△■■■□□□□○◆◇□△△○ ○■ + ○◇◇◆□ ◆△◇◇◆ ● + ◆○○ □○◆ ▲■▲△○◆△ + ▲ ◇□□●○□◆◆●□△◇■ + ◆□●△◆▲□◇□◆□□ + ◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_18.txt b/codex-rs/tui/frames/shapes/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..16bf8c1b581a7cd18d51edc26ab6f9159bb49661 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_18.txt @@ -0,0 +1,17 @@ + + ◆●●□●●□●◇▲◆ + ◆□■◆●▲□□◇◆◆▲■□●●◆ + ◆△△◇□■□●■▲■△◆■□△△○ □ + ▲◇◇□△▲○■□ ■○◆◆△●◇○●○○○▲ + ◆○△△ △◇ ◆■□◆○△◇△◇◆○●○○◆ + △○◇ ▲▲◇△◆◆◆△▲□▲◇■▲△○△◆△◇ + ◇◇○△△ □△□△◇■◆△■□◇●◇●▲ + ■○▲◇◇ ○○■◇◇●○ ◇●◇■ + ○■■ ▲○●●●●●□◇◇▲◇□△○▲ ◇□◇◆ + ◆○○◇□○□ ◇◇△◆◆○◆◇◇◇◆●◇◆○ ▲ + ◇△◇○◆■■□□□□□◆ ○◆◆△◇△□△◆▲◆ + ○○ ▲○▲ ◆◆□■△□◆■ + ○○□□○○△ ◆□○◆△○▲◆ + ■◇△□□□□●●●○■□◆△◆▲□ + ■◇◇◆△◆◆▲△□■●□■ + ◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_19.txt b/codex-rs/tui/frames/shapes/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..e1bc51ae1be419ddf0bc07d7c686c4ec226087ce --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_19.txt @@ -0,0 +1,17 @@ + + ◆◆●●□◇□●●◇△◆ + ◆□◇◇◇○△●●●◆□◆▲◇□▲▲ + ▲□●●△■◇□□◆ ◆□△▲◆□●○ + △●◇□▲○◆ ◆●●□▲○◇□▲ + ◇◇△◆△◆ ▲◇○■△■◇○ ◇○▲ + ◇○△□■ ◆◇□◇△ △△ ■△□◇ + ○●◇◇■ △□○△◇□▲◆ △△△□◇ + ◇◇◇◇ ◇●■○◇■▲ ◇ ◇◇◇ + ◇□◇○▲●●●●●●◇◇◇◇□□●□◇● ○ ■◇◇ + ○●△▲◇●□△○□□○▲◆▲ ○◆ △○◆○▲△△◇◆ + ■○■△□◇■■■■■□□■ ○○◆△◇△△ △△■ + ○○○□◆○ ◆△△◆△△■ + ■ ◆○◇◇□▲ ▲●●△△◇●◆ + ■■△◇□△◇□◆◆●●△□■ ●□◇■ + ■◇△◆△▲■◆▲△○□▲○ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_2.txt b/codex-rs/tui/frames/shapes/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..af71459f5e9ace82223090911bbd826d00227f24 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_2.txt @@ -0,0 +1,17 @@ + + ◆△◆△●□●●●□▲◆ + ◆□▲△□●□◇◇◇●◇◇●◆■◇□□◆ + ▲◇□◇○□□◇□◆◆ ◆■□●□△◇◇●◇▲ + ◆◇△◆△□■▲▲◆ ▲▲○◇△◇▲ + ▲○■△□○◇○◆○○◆ ○▲◇○◇▲ + ■△□■◆ ◇□○□△○□ ○○◇□◇ + ▲◇■□◇ ○△◇●◇◆◇▲ △■◇■◇ + ◇■ ○◆ ▲◇□◇◇●□ ◇■△△◇ + □■■●◇ ●◇○○◆◇■△◇□□□□□□□□▲◇◆◇◇□ + ◆●■◇ △◇◇■ △□ ◇●○◆◆◆◆△△◆◆◇□△ ◇ + ○○○ □ □○△△◇■ ◆■□□□□□□□■◇▲□◇◇ + ■○◆△△▲ ◆ ▲□●△▲□ + △○◆◆◇□▲ ▲●◆□ ◆◇■ + ◆◇○●◆◆■□□△●●●●△●□◆◆◇◇□ + ◆□□□□◇◇◆◆◆◆◇●△□■ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_20.txt b/codex-rs/tui/frames/shapes/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..c5eb01382d64d393db72ac245a7baeaba6fae1d5 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_20.txt @@ -0,0 +1,17 @@ + + ◆◆□●◇◇●□●▲◆◆ + ▲●●■●▲◆▲●●●◇◇◆○□○□▲ + ▲▲■◆□□■◇□■ ◆■○■○□□◇□◆ + ◆◇■◆□◇□ ◆▲□◇○△□○○ + ◆◇ ▲◇□ ◆△ ◆◇◇○□○◇◇○ + △◆▲◇■ □□■●◇■△ ■○◇◇○ + ●◇●□◇ ▲□ △□□○ ▲○◇△ + ◇◆○△◆ ■■●△◇■▲ ◇◇■◇ + ◇△◇◇●▲●●●●●●●●◆▲■●○○○○□ △ ◇○ + ■◇○◇◆■ △◆◆◆◆◆◆◇○ □○○■◇●○ ▲△ ◇◇ + ○▲○○■■■■■■■■□■ ○▲●◇◇▲◇ △○ + □ ○□△▲ ◆△□△◇△ + ○◆○□▲◇◆ ▲●□◆●□■ + ○▲▲△■◇□○●●▲△■◇■◆▲△□■ + ◆□□◇◆ ●◆◆◆◆◇□■■ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_21.txt b/codex-rs/tui/frames/shapes/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..944b99f0581900c998b478a5ff3a7bbefaa2dfb1 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_21.txt @@ -0,0 +1,17 @@ + + ◆◆▲●□●●□●□▲◆◆ + ▲□□◆◆◆◆□◇●◇□●◆◆▲□□◆ + ●□○◆□□○■◆ ◆○◇□□◆◆□▲ + ▲□◇●■■ ◆▲●□▲●□▲ + △▲△□◆ ▲◇△◆○ ○○◆○▲ + △■△◇ ▲□□◆△△ ○○○▲ + ◇■◇◆ ◆ △◆▲△■ ○●○■ + ◆○ ○● ■◇▲ ◇ ◇ + ◇ ◇ ▲□●●●●●□□□ ○◇▲■◇▲ ●△ ◇ + ◇ ◇○ ◇◆ ◆▲◇ ○■◆□◇▲ △■△△ + ● ■△ ◆◆■■■ ■◆ ■□◆▲● △◆ ▲ + ■□ △●◆ ◆□■ △ + □◆ ○▲◆ ◆△●□■ ▲◆ + □●◆ □■□□△◇◇●△●□■■ ▲◆ + □□□●□◆◆◆◆▲◆●□□◆ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_22.txt b/codex-rs/tui/frames/shapes/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..60ea930d46d76e90ae8d4d40ba76760d8c84f3e4 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_22.txt @@ -0,0 +1,17 @@ + + ◆▲□●□●●□●□▲◆ + ◆□△□◇△◆△◇△○◇◇●◇□△□□◆ + ▲●□●◇○□□◆ ◆■□○□□□◆◆□▲ + ●□◇□□■ ▲▲◇◇◇◆□○ + ○◇◇△■ ▲◆△◇●◇△△●■◇ + ○◇□△◆ ▲◇△◇●○△◆ ○◇○●◇ + ◇□□○△ ◇□◇○□△■ ◇◇△◇ + ◇◇●◇◆ ◇○◇◇○○ ◇○◇■◇ + ◇◇▲■▲ □◇●●●●●●□□◆○□◇◆ ○▲ ◆◇●■ + ○◇● □◆○○▲ △▲■◇△△◇ ○□●◆■◇▲▲ △○● + ○○○◆◇ ■■■■■□□■◆ ◆△□□◆△△△■▲◆ + ○□○◆○▲ ◆□▲■◇▲■ + ■■□● □□◆ ◆△□▲■□◆△ + ■△□□▲■□◇●●□●●△●□■■■▲□ + ■◇■◇◆●◆◆◆▲●◆△●□◆ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_23.txt b/codex-rs/tui/frames/shapes/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..5d340640bf39d46616f1134590b69fe968a3f3f7 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_23.txt @@ -0,0 +1,17 @@ + + ◆▲□◆●□□□□●□◆◆ + ◆●◇□◇○◆▲◆●●▲◇◆◇□◇□◆ + ▲■□◇■▲●□◆ ■◇△□◇○●◇◇▲ + △●◇□●△◆ ◆●●○◇◇●○ + △△◇◆△■ ▲△▲▲■□△□○◇○ + ▲○◇■△◆ ◆□□□■■△□■◇◇○◇▲ + ◆◇◇■◇ △△△△ ◆◇◆ ■ ◇■□ + ◇△◇ ◇ ◇■◇▲◆○▲ ◇ □△◇ + ○■○ ◇□◇●●●●●●●●◇◆□◇◆■○◆ ◇ ◇◇◇ + ◇○○◆◇■●■ △◆○ ○◇◆○▲◇ ○◆◇ + △■◇ ◇ ◆■■■■■■■ ■◆△●●◇◆△○△◆ + ▲◇◇◆◇△ ▲△◇○○△◆ + ○ ◇▲■△◆◆ ◆△■●□△□△ + ○●○□●□□○◇●●◆□□■▲◆□□◆ + ■◇ □●○◇◆◆□◆◆□□◆ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_24.txt b/codex-rs/tui/frames/shapes/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..558224147dc3fdcf1be9bd58249b955250a16fc0 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_24.txt @@ -0,0 +1,17 @@ + + ◆◇◆●□□□●●▲◆ + ▲■◆△■◇◆◆◇●●□▲◆■□ + ▲■■●○△□■◇ △◆△□◇□□▲ ○ + ▲■△△▲△◇●▲○◆◇△◆ ◆□○◇○◆■ + ▲ △△○△▲ ◆■■△○◆◇◆◆○◇▲■ + ◇▲○□○◆ □▲▲△ △◇● △■○ + ◇ ◇◇ ▲△◆▲△■△○○◇○○◇○ + ◇ ○◇◇ ◇▲●△○◆◇◇◆△△◆◇◇ + ◇△ ◇●●●△□□◇◇◇□□◆□▲◆◇ ◇ ◇◇ + ◆ △ □◇△▲▲●●▲▲◇△○○□□◇ ◇□◇ + ○ ■○◇○□■■■■■■◆ ○▲△□○◇●△▲◆ + ○ ○▲□○ ◇ ■△●△ + ○△□○◆◇△ ◆●■◆△△◆□ + △▲□▲○○■◇◇△●●●◇○◆△◆ + □△◆□□●○◆◆●●□□ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_25.txt b/codex-rs/tui/frames/shapes/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..38d32507640a71c91a55e93d7741064033ca279a --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_25.txt @@ -0,0 +1,17 @@ + + ◆□●□●□□□□◆ + ▲ □▲□◆◆●○◇□◇◇▲ + △◇△△▲▲■△◇□○●○◇◇◇◆ + ◇□□◇ △ ●◇ ■○◇▲□○△ + ▲◆◆○▲△■○●△◆◇■ ◇△△○△□ + ○▲◇◇◇◆□□△○◇△ △◇▲◇△△◇▲ + ◇△◇□◇ ◇◆◆△○◆△◇○△◆■◇●◇ + ◇◇◇△◇△■ ◇◆●○△◇○◆◇●◇◇○ + ○□○◇■◇◆◆●●●●□◇◇○○◇□□◇○ + △▲■◇△◇▲◇◆◇◇●□◆◇○□●△○◇ + ○◇□△○◇◆■■■■■■□△◇○◇△◇◆ + ◇◆■◇●□●○▲▲□△◆△▲△△◇△ + ○◆○◇◆○◆□●△▲○△◆◇◇△ + ○△●○○◆□●◇□□ □◇□ + ◇▲■□○□▲◆□◇□ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_26.txt b/codex-rs/tui/frames/shapes/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..4aac44389a95c41f330973d71d128a3e1f23f350 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_26.txt @@ -0,0 +1,17 @@ + + ◆●□□□■□▲ + △□◇●△○◇□●◇▲ + △□■■◇●○◇○■■○▲ + ●□■▲◇◇◇◆◇◇◆□△○ + ◇◆▲○◇△◇■□○△●◇○● + ○ ■◇△△△■□◆○■○○△ + ◇ □■●◇◇△◇◇●●■□ + △▲ ○◇◇◇○○●◇◇△◇◇ + ◇ △○□●□◇□●●△○◇◇ + △◆◇△▲◇◆◆▲□○○□◇ + ◇◆■△◇□■○▲△◆□○◇◇ + ○◆◇◇△○ △△■◇◇◆ + □ □◇○ ○◆△◇△▲ + ◇■□○○■□◆□□● + ○△△○□◇◆○□ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_27.txt b/codex-rs/tui/frames/shapes/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..9896590f79774d8c726b4442b20cc2c0461ec662 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_27.txt @@ -0,0 +1,17 @@ + + ●□□□□▲ + ◇●△△◇△○ + ●■◆ □◇△■◇ + ◇◇◇○◇◇●◇◇ + ◇ ◆◇◆▲■● + ◇◆△◆■◆◇◇◇◆ + ● ■◇◇◆□◇◇ + ◇ ◆◇◇◇●■◇◇ + ◇△ △ ◇□◇◇◇◇ + ■● △■◆◇◆□◇◇ + ◇▲ ◆○□□□△■ + ◇◇□□◇◇◆△◇ + ◇●●□●▲△◇○ + ○◆◆◇○●△△ + ■○■ ○○△ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_28.txt b/codex-rs/tui/frames/shapes/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..16b349dc3d5f4154193d62bed902673e7fecfd86 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_28.txt @@ -0,0 +1,17 @@ + + △□□● + ◇□◆◇◆ + ● ●◇◆ + ◇▲◆□◇ + ◇ ◇◇ + ◇ ▲■◇ + ○◇◇ ◆◇ + ◇▲■■◇ + △○ ◆◇ + ◇◇△△◇◇ + ◇ ◆◇ + ◇●□●◇ + ◇●□◇◇ + □◆◆△◇ + ◇ △□◇ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_29.txt b/codex-rs/tui/frames/shapes/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..24be1563b27acbe9eee140f9a81e43f20c2ae5da --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_29.txt @@ -0,0 +1,17 @@ + + □□□□□▲ + ▲◇□△ □○ + ○▲◇◇ ■■◇ + ◇◇◇◇○○▲△ + ○ ◇■◇ ◇ + ◇▲◇◇◇ ◆◆ + ◇○○◇◇◇ ○ + ◇◇●◇◇ + ◇◇◇□◇ ◆ + ◆◇●◇◇ ◆ + □◇◇△◇○ ● + ◇△◇◇◇ ◆◇ + ◇◇◇◇◆◇◆◇ + ◇●◇□△■◇■ + ◇◇□ △▲ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_3.txt b/codex-rs/tui/frames/shapes/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..3f55b79ac59ee73a73efc38d32f7abe2c0e2dbd6 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_3.txt @@ -0,0 +1,17 @@ + + ◆△●●□□●●●●▲◆ + ◆□▲△○◇◇□◇■●●◇●◆○◇□□◆ + ▲●△□●◇□■■◆ ◆■◇ □□□△○◇▲ + △□△○◇●▲□◆ ◆●□ ◇◇○ + ■△□◇△○△◇◇□○◆ ○○○◇◇◆ + △◇○○△ △▲◇○●○○ ◇ ○○◇ + ●□ ◇ ■□○◇◆■◇▲ △□△◇▲ + ▲▲◇▲◇ ▲▲◇■△○◇ ◇◆◇●◇ + ▲■■◇ ●◇□□◇◇□▲◇●□□◆◆◆□□◆◇○■●◇ + ▲○△○▲ △△□△●●◇ ◇□◇◆◆▲ ●△▲●○◆◇○◇◆ + ■○◇ ◇ ■◇◇□▲■ ■□□□■■■□□▲△△△▲△ + ■□◇ ○□ ◆ △○□○△△ + ●○△△□○▲ ▲□●△■▲◇■ + ●○□▲◆□□○●●●●▲△●□●◆◆□■ + ◆□□□◆△◇◆◆◆◆◇●△○■ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_30.txt b/codex-rs/tui/frames/shapes/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..54886a319d094c89b48c1dec2630e7adbb4d2372 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_30.txt @@ -0,0 +1,17 @@ + + ◆●■□□□▲ + ◆△●◇◇○□■●○ + ■■○◇▲○○◇□▲○ + ◇◇○◇△◇◆○◇○ ○▲ + ●□◇◇◆○◇▲◇▲ ◇ + ◇□◇○□ □◇◇◇◇△◇ + ○△◇◇◇○◆ ○△○◇ ●◆ + ■◇ ■□●◇▲◇■◇▲ ◆ + ■◇◆■○◇●◇○△◇▲ ◆ + ■◇◇○◇◆◆◇◇ □▲ ◆ + ○◇◇●○◆□◇◇◇□◇◇ + ◇□△△▲△□○◇◇■ ◇ + ○◇○◇◇△□◇◇▲◇△ + ▲○▲□●○△◇▲△■ + ○▲△◇○◆◆●■ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_31.txt b/codex-rs/tui/frames/shapes/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..b3989b89df99ff1093f90260ac33ff1d1e61421e --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_31.txt @@ -0,0 +1,17 @@ + + ◆△△□□□□●▲ + ◆□□●□▲◆■□○■□▲ + ▲◇○△△▲●◆◆△■◇◆□○ + ▲◇△□◆◆□●◆▲○○◇○◇◇○ + ○◇◇◇◇□▲□○◆▲■○◇△◆◆ + ●◇○◆◇◆◇■■○●□▲△△◇▲■◇ + △◇◇ ◇○□◇○△●△●□◇□◇△◇ + ○○▲ ◇◇■◇○ ◇○■■◇◇○△◇ + ◇○▲◆◇■○◇◇◇△□◇◇◇◆ ◇ + ■◇◇◇△△◇◇◆◆◆○◆◇○◇◇ □ + ○□◆△□◇▲ △□□□◆△◇■◇◆ + ◇◇◇○○○▲ ◆●□ ◇ + ●◇□●◆ ▲ △ ◇□◆△◆ + △◇○◇△◆◇◆▲◇■▲◇◆ + ■■●◇◆□□△△●□ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_32.txt b/codex-rs/tui/frames/shapes/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..919eee3b0fdf51e401996c32721f94b38ec3e5f1 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_32.txt @@ -0,0 +1,17 @@ + + ▲□□□□●●◇▲◆ + ●□◇□□◇○◇◇□□◇◇▲ + △○□●○▲□○△▲◆□◇□○■□▲ + ■△△◇◆▲◆●▲ ▲ ▲◆○◇□▲ ▲ + △△◇▲◇□○■□◇● ■◆▲◇○△○□○▲ + ○△○○▲○□◇◆△◇◇□◆ ◇□△ □ + □◇■●▲◆■○◇◇▲▲◇▲●◆△◆△△◇■◆ + ◇◇◇▲◆ ■◆◇◇■△■◇●▲◆■○◇■■◆ + ○◇◇▲◆ ■◆◇■◆◆◇□□□◇◇◇●◇■●◆ + ■■■◆◆◇□○■○◆◆◆◆◆◇◇□◇●◇▲◆ + ▲○□◇◇△ ◇□■■□□□○●△△△△△ + ○▲◇○○○ ▲○◇△■◇ + ■□□◇◇◇□◆ ◆△◆◇△ ▲ + ○▲○■◇□◇●●□◆◆□■◆△ + ◇●●◆◆▲□△□◆■■ + ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_33.txt b/codex-rs/tui/frames/shapes/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..c5598aa7a7392e8c0b10dd9aea39fa0a5ec53d97 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_33.txt @@ -0,0 +1,17 @@ + + ◆▲□●□●□●◇△◆ + ◆□□◇◇○□●◇△◇▲◇□◇□△ + ◆△○◇□□□▲●■◆ ■□△◇◇□■▲ + ▲○△◇△◆◇▲◆ □◇◇○◆□ + ◆△◇△◆◇◇△○○○ ◇□○○○ + △△▲ ○◆○○◆○▲ △○△◆○ + ◇◇□ ○▲□◇◆◇○ ◇□◇◇ + □◇▲▲ ▲■■○△●▲ ○■◇□ + ◇◇◇ ◇ ▲◆ △□▲▲▲●●●□◇◇□△◆◇○◇ + ○△◇■■△△▲△◆◇◇◆◆◆◆◆◇◇□○○○◇◇△ + ○●○▲ ◇△◇△ △ ■■■■□□□ ◇◇▲△○◆ + ○□○□▲■▲ △△●△●△ + □○■□▲□●▲ ◆□◇▲△□○■ + ○◇■□○□◇▲●□◆■●▲○□◇□ + ■●●□▲◆◆◆○□□●▲■ + ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_34.txt b/codex-rs/tui/frames/shapes/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..5a44de8256122f1e58e298559941e606905009e4 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_34.txt @@ -0,0 +1,17 @@ + + ◆▲●□□□□●◇▲◆ + ◆□□□△○●◇■◇□□●□□●▲●◆ + ◆△△▲◇□■△◇□◆ ◆○○○▲■□□▲ + ▲△◆◇□◆△▲◆ □◇▲□○○ + △△◇○▲△◇ ◇■ ▲ ○○□○○ + ▲△○◇◆△ ○○■◇○□○ ○▲○◇○ + ◇□○◇◆ ■○◆□◇◆○ ◇●◇◆ + ○◇●■△ ◇□◆◇△■◇ △ ◆○ + ▲■△△■ △○△△■◆●●□□□□□□◇◆◆■ ◇○◆ + ◇◇○▲△▲▲○■△△■◇◇◇◆◇△◆◆◆▲△◆◇◇◆◇○ + △◆○▲ ◇○◇□○ ■□□◆◆◆◆○ ▲△▲○◇■ + △▲○●●○◆ △●△◇△■ + ○□◆◇□○●▲ ◆□●■◆△▲◆ + ▲○△◇■◇□▲●▲□●□●□◆□□△■ + ■◇○●●◆◆◆◆◆●●□●○ + ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_35.txt b/codex-rs/tui/frames/shapes/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..1c1728676b2028ffc1312c55e4925c1fe00ef16c --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_35.txt @@ -0,0 +1,17 @@ + + ◆▲□●●□□□●□▲◆ + ▲□●□△◇□□□■◇□◇△■◇□●◆ + ◆△△◇●□□◇○□■◆ ◆■□●□△■◇□▲ + ▲△△□△▲●▲◆ □□◆○◇◆ + △△◇◇△△○ ■○□▲ ◇○□◇▲ + ●△●△□◆ ■◇◆■△△□◆ ◇○○◇ + ○ △△ ○○ ■□◆△ ◇○○○ + ◇◇◇△ △○○●△■ △ ■△ + △ ▲◆ ▲◇◇△●■△◇●●◆●◆●□◇◇ ◇◇●□ + ◇▲○●■ △△●■□□△◇● ○▲◆◆◆◆△■◇△■ ◇■ + △◆○▲■△○◆◇△○◆ ◆■○○○○□○○□△△◆▲◇ + ■△○○△◇ ◆△□ ▲○ + ○●◆○●▲△▲ ▲●△◆□□■ + ■○◆■◇□◇▲△◇◇□◇●△□■◆△●■ + ■○◇●●◆◆△◆◆□□□■○ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_36.txt b/codex-rs/tui/frames/shapes/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..0cac995ed7ab52707688d4d391f7b2e2bee71228 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_36.txt @@ -0,0 +1,17 @@ + + ◆◆□●□□□□●□▲◆ + ◆●■■○□△●△□○□●□□■□□□◆ + ▲○■○▲□□▲□■◆◆ ◆■□◇□□◇▲□◇▲ + ◆◇■□□◆◆◆◆ ■□◇○■◇ + ▲△◆●○■◇○◆○▲△ ○○△□ + △ ▲△◆ □◆ ■◇△▲ ■○○□ + ◇ ● ◆ ○ ■○○ ◇○◇▲ + ◇◇○◇ ◆○ ◆△△ ○◇ ◇ + ◇■■◇ △△ ▲□■■▲□□◆□□□□□◆ ◇○◇◇ + ◆□◇○ ▲◇◆◆△◇△ ◆◇◆◆◆◆◆◆◆◇◇ ○△■◆ + ■ □◇○ □◆□△○ ■□□○○○○○■ ◇▲■△ + ■▲■○●△ ▲△◇■△ + ○△◇◇□◇▲ ▲□□■△▲◆ + □▲◆■□□●○●◇◆□◇●■▲■▲●□■ + ◆□□△◇◆▲▲◆▲▲□□□□■ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_4.txt b/codex-rs/tui/frames/shapes/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..31e55f9cb8c896d15918c3ed9d2bee6e2c1a9e97 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_4.txt @@ -0,0 +1,17 @@ + + ◆△●●□●●□●□▲◆ + ◆▲■□□◇△ ◇○●△□●◇■△□▲◆ + ▲□●◇◇◇□□■◆ ◆○◇○○□◇△○○◆ + △△◇▲◇■△□▲ ■◇□□○□○ + △◇△◆◇●◇○●○◇▲ ○■○◇□○ + △◇◇○◇ ■◆◇◇△□○ ■ ○●◇▲ + ■○◇●◆ ■□□○□■◇▲ ▲ ■●◇ + ◇△○ ▲◇△□○◇◇ ◇◇◇ + ◆◇ ◇◆ ●◇△■○△△□◇□□□◆◆◆◆▲▲ ◇◇□ + ◇○△ ◇ ▲△△△▲◆◇◆●○◇▲△◆▲△△○●◇△△■◇ + ◆◇●■○▲○○▲○△ ■□□□□□□□■○▲◇◇◇◆ + ▲◇○■◇◆◆ ▲○●△□□■ + ○○▲△○ ▲◆ ◆△○▲□●◇△ + ◇◇◇●◆□□○●●●●▲□●□□○◇□◆ + □○○◇△◇◆◆◆◆◆●▲□◆ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_5.txt b/codex-rs/tui/frames/shapes/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..a8ae0ab8193766ca0668d84477dd2fa3b372c2dd --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_5.txt @@ -0,0 +1,17 @@ + + ◆△●●●□●●●□▲◆ + ◆□■□△□△◇◇□◇●□●◆◇■□▲ + □◆△○□◇□■◆ ■○●◇□○ ▲□▲ + ▲◇△◇△◇△●▲ ▲○◇◇◇◇◆ + ▲○△□△○◆□◇◆○▲ ◆ ○◇○◇◆ + ◇◇◇◇△ ○◆◇○●△◇◆ □△◇◇ + ◇△◇ □ ◆○◆◇○■◇▲ ■■◇□◇▲ + ◇△◇◆■ ▲◆●◇△◆◇ □◇◆◇ + ◇▲◇○□ △◆◇■▲◇□●◆◇□◇◆◆◆□▲◆■◇△◇ + ◆○◇○▲▲△□□■ ◇■ △△○●○▲●△▲△◇△□◇◆ + ○▲◇◇▲◇▲○○△□◆ ■□□■■■■■▲◇●◇○△ + ○○□△○○ ◆ △◆△□▲○ + ○△□○◇●□◆ ◆●□▲□■●□ + ○◇□◇▲□□○●◇●◆▲□●□○◇●□ + ○▲□◆□■◆◆◆▲◆▲□□◆ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_6.txt b/codex-rs/tui/frames/shapes/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..e0b1f854547b065eb4cd3331afa0417188d1338c --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_6.txt @@ -0,0 +1,17 @@ + + ◆△●●●□●●□□◆◆ + ▲○●△□◇●□□◇□□●◆▲□□◆ + ▲▲▲◇◇◇□■ ◆■◇△●□□□△○◆ + △▲◇□◇□◆●▲ ○□◇◇◇◇▲ + ■●◇◇◇□○○□□○ △○○◇○▲ + ●▲△◇△◆○□●◇○◆◇▲ △●○△◇ + ◆ □◇◇ ○○◇△○○ ○□◇◆◇ + ◇ ◇◇◇ ◆◇●◇△◇◇ ◇□○◇◇ + ◆△◇▲◇ △■◇◇□●△□◆◇□◇◆◆□□◆△○△◇ + ◇◇○▲△◆○◆●□ ◇△●□▲◇◇▲●●●○◇▲◇○ + △ ◇ △◇◇◇●△■ ■□■■■■□■◆○○△◇ + ▲ ● △▲◆ ▲△◆△△◇◆ + △◆○◆□□▲ ◆□○◆△□▲□ + ◆■◇□▲■□ ●◇◆◆▲□□□ △●■ + ○▲●□□▲◆◆◆◆◆▲□□ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_7.txt b/codex-rs/tui/frames/shapes/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..7e69d68d573f825e72cf184c4b5ef2b456d5cf8e --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_7.txt @@ -0,0 +1,17 @@ + + ◆△◇●□□●●□▲◆ + ▲□◆□○◇□□□●●○● ◆□▲ + □ ▲◇◇◇□◆ ◆○◇◆ □△◆□▲ + ○○◇◇◇◇●□ ○◇○□◇△▲ + ◆■◇□◇◇○◇△○▲ △ □◇◇○ + ◇ ●◇◇■ ◆■○●◇○ ▲▲◇◇◇ + ◆○■◇ ○◆◇○◇○◇ □□○◇◇ + ◆■△◇◇ ●□△◇□◇◆ ■ ◇◇◇ + △■◇□ ▲■◆△◇○△◇◇●●◆◇◇●●□○◇◇ + ◇▲■◇◇●△◇◇△■◇△○◇◇○○▲●▲●○◇○◇ + ○ ●○△□◇□◆△ □□□□□□■■◆△◇◇ + ■◆ △◆△◆◆ ▲■▲△●△■ + ■●▲□○○○◆ ▲○◇▲□△□■ + ○◆■○◇□○●◇◆◆◆□□◇□□△ + ■◇□■◇△◇◆◆▲◆●□□ + \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_8.txt b/codex-rs/tui/frames/shapes/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..b7bddd4156ac906329bb057c240cd15b30f07634 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_8.txt @@ -0,0 +1,17 @@ + + ◆◆□◆●□□●□▲◆ + ▲■□△◇◇◇□●● ◇ ■□ + ◆◇●▲◇△△□ ▲■◇△◇□○□○▲ + ◆□○□△◇▲□◇ ■ ●○◇◇○ + ■ △◇△▲○◇△○ △■○◇◇▲ + ◇■▲◇○○○◆○◇◆□ ○□◇◇◇ + ◇□◇◇◇ ■◆●○◇◆◇ ◇○◇△◇▲ + ○▲◇◇△ ◇□○△◇▲□△ △◆△◇◇ + ○ ○◇◇◆○ △□●◇◇△□▲□▲▲○◇◇◇ + ○□△●◆◇■▲◇▲◇◇●◇●◆◆◇◇●■ + △△○○◇○◇◇◇△■○□○□■○□■△○◇ + ■▲■◇○◇◆◆ △▲△△△■ + ■○◆□○○○◆ ◆△○◆◇□△□ + △△■◇▲□ ●●□□●△●▲◇◆ + ◇◇◆□□○◇◆▲□△●◆ + ◆◆ \ No newline at end of file diff --git a/codex-rs/tui/frames/shapes/frame_9.txt b/codex-rs/tui/frames/shapes/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..4342d3c81e5510622abe4a21ebdc0d18d383fde2 --- /dev/null +++ b/codex-rs/tui/frames/shapes/frame_9.txt @@ -0,0 +1,17 @@ + + △●○□△□□●▲ + △□◆△◇◇□■● ◆▲○◆ + △ ▲◇◇△□○○●△ ○◆◇▲ + ■ ◇◇△●●◆ ◆○▲◇○○◇□▲ + △□■●○□■◇□▲ □△■□△□◇◇ + ◇ ◇◇◇■○◇○●◆◆ ◇■◇□◇◇◇ + ◆ ◇□○◇□□◇○○▲■△▲△ ◆◇◇ + ◇ ◇△◆▲○◇◇◇◆△●○◆◇ ◇◇ + ■▲◇■ △◇■◇◇◇◇□◇◇▲◇◇◆ + ◇◆◇◇◇◆▲◇○◇◆◇◇◇◇◆◆◇◇◇ + ■ ◆○△○○◇△■□□□□◇◇■△○◇ + ○◇◇◇○▲◆ ▲△ ○△◇◆ + ○□○◇○○ △●◆●◇△□ + ○▲■○◆◇□△□●●●□◇◆ + ■▲◆ □○○◇●□△■ + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_1.txt b/codex-rs/tui/frames/slug/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..514dc8ac49ccd75bed5995c030d755d5e83f6e76 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_1.txt @@ -0,0 +1,17 @@ + + d-dcottoottd + dot5pot5tooeeod dgtd + tepetppgde egpegxoxeet + cpdoppttd 5pecet + odc5pdeoeoo g-eoot + xp te ep5ceet p-oeet + tdg-p poep5ged g e5e + eedee t55ecep gee + eoxpe ceedoeg-xttttttdtt og e + dxcp dcte 5p egeddd-cttte5t5te + oddgd dot-5e edpppp dpg5tcd5 + pdt gt e tp5pde + doteotd dodtedtg + dptodgptccocc-optdtep + epgpexxdddtdctpg + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_10.txt b/codex-rs/tui/frames/slug/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..bd3b8fafff4256c6dd3731839f2ffde68d363fb8 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_10.txt @@ -0,0 +1,17 @@ + + dtpppottd + ppetptox5dpt + ddtee5xx-xtott + edd5oecd-otppoot + 5 ceeged pt5d5e5 + ee pepx55o gedge + o xpgpeexep e5t + g eeot5tee-de-oee + g xo ooecxxtotcee + e teoted5dpdddepe + t geeeeegggotgoee + oeptotpg dxggt55 + ep eeexptct5e5e + cepp5etcdg55p + pt dpodtcp + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_11.txt b/codex-rs/tui/frames/slug/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..9eaf147a6a09807c864319aec21f3ba1ad0553d2 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_11.txt @@ -0,0 +1,17 @@ + + tppppttd + 5g ceeeoot + 5gddeecop5ot + eddeeoctdo55 + dg-coetopcdeet + eteeetdcced5ee + pp teeeeeedeoo + e ee5eeo5 ege + 5 pe5eep5tede + pp de5otg5eded + pe- eeeeo5eooe + od-5edp5ppcee + gd peooddecg + otgpeetd5pe + od pptdte + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_12.txt b/codex-rs/tui/frames/slug/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..11163a99b9b14a3c0ab728c23ba85581cc5b6a42 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_12.txt @@ -0,0 +1,17 @@ + + tpppt- + toed5eto + g e5ott + 5txeeooge + e pxeee-5e + ep--pdgdeg + e x5oeeo + e 5toeeg + pt x5eetex + e 5epcpd + e- egopp5 + t pegdte + 5 ppetpoe + pd5gteoee + o pxo-5 + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_13.txt b/codex-rs/tui/frames/slug/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..eb072e40ad2cf05e0ce71b8986b4e68e3dfc701b --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_13.txt @@ -0,0 +1,17 @@ + + 5pppt + eddee + eedeg + epped + p ee + gc-ee + t ee + t ge + 5t dx- + eg toe + pe-- xe + eddde + etted + pddeo + t -go + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_14.txt b/codex-rs/tui/frames/slug/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..100f30930230bda2947121aad238380ead7d5e57 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_14.txt @@ -0,0 +1,17 @@ + + tpppc + t5dd-o + edee- e + ee5egdxt + eeeo e + xpee pe + eeee - p + eeoee o + eeex g + gd55 c5 + oeggt-te + epxeddde + 5ggeoooe + eo5pdd5 + dp po5 + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_15.txt b/codex-rs/tui/frames/slug/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..5761f309d46f1c80e162044af073beaec5d404c9 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_15.txt @@ -0,0 +1,17 @@ + + ttpppxd + etoeedcpt + 55epooegpe + e5e 55t-dde + eeogooee5gde + oee-ee55e g + ee eeeexxte + ee5xeteee p-e + eetttpeeed-ce + edec5eoxp- -e + e5eeeede e c + epp-dxeo- o + peot 555e ce + edeeoo-to5 + odpdddd5 + ee \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_16.txt b/codex-rs/tui/frames/slug/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..f9001140ed831735ea1a1dd367840b5887062161 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_16.txt @@ -0,0 +1,17 @@ + + dotgpptxd + 5deepeeoeoo + e55go5ooee po + 555 cpdoeoeeppe + eecd5ppoeep5eeo + eeepep eoo ge x-e + ooe 5eteeee5pgt e + e5c eeee5eeegc ee + ooexetx5deegpt 5 + pgddtooope-de tde + eeetgg5poecgc-xee + eep ee e55t 5 + oep e 5t5dte + oexgdpx55tde + pc-docddcp + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_17.txt b/codex-rs/tui/frames/slug/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..696d932d409cd3abf07aa70155107b4469380dec --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_17.txt @@ -0,0 +1,17 @@ + + totttccxtd + dcppexxpopetgdt + tpo5dooettgeeedgo + 5e5d5egde pecoxeeoo + e5d x eg5ooo55 t + eopt5e tc5e 5to5e-5 + pgc5e t55ed pgee5oe + -goeg g55ee5eteeocp + t5p5oootxodeodcoeee e + egdcdde5po5eeogotpto + ooo5gggppppodep55o op + oeedp e5eee c + doogpod tpt5dd5 + t xptootedcpcep + etc5dttxpdtp + e \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_18.txt b/codex-rs/tui/frames/slug/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..abb0da53d29a24a3bed81e9728422b9a59819584 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_18.txt @@ -0,0 +1,17 @@ + + dootootcxtd + dtgdctttxddtgtccd + d5cepgpogtg-dgt55o p + teep-tdgp poed5oxocooot + do55 5e dgtdo5x5edocood + 5oe ttx-ddd5tptep-5d5e5xg + eeoc5 t5p5egd5gpeoeot + go-xe dogeecd eceg + ogg tooococtxetep5ot epee + dooepop xe5ddodxeedcxeo t + e5eoeggpppppe odd5e5p5e-e + oogtot eepg5pdp + odptdd- dpdd5ote + pe-ppttooodppd5dtp + gxed5ddt-tgctg + e \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_19.txt b/codex-rs/tui/frames/slug/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..ffc4d2b475515eef45d80efcd250607a0553f278 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_19.txt @@ -0,0 +1,17 @@ + + ddootxtoox-d + dteeeo5ooodpdteptt + tpcc5getpe epctepco + 5ceptde doottdept + ee5e5e tedg5geo eot + eo5pp depx5g-5 p-pe + doexp 5pd5ette 5c5te + eeee ecgoegt e eee + epeotoccoooxxxetpcpec o gee + dc5teop5dptotet dd codot5-ed + pog5tegggggppg dod5e55 55p + oodpdo e55d55p + pgdoxxpt tco-5ece + pg-ep5xtddoc5pg cpxp + gx-dc-pdt-dp-d + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_2.txt b/codex-rs/tui/frames/slug/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..f4419e3d693ad20a151df3315ce1431c410c4ad5 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_2.txt @@ -0,0 +1,17 @@ + + d-dcotooottd + dtt5pcteexoxeodpeptd + tepeoppxpee egpop5eecet + de5d5ppttd -toe5et + tdg5pdeodood dteoet + p5tge epot5ot ooepe + teppe d5ecedet 5gege + eg oe tepeecp ep5-e + pggoe cedddeg-xtttttttttedexp + dope 5eep 5p eoodddd--ddet5geg + ooo p po--ep egpppppppgetpee + pod-5t e ttc5tp + -oddett todtgdeg + exdcddgptccocc-opedeep + eptptxxddddxc5pg + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_20.txt b/codex-rs/tui/frames/slug/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..0039bd880b1cec865dd597dab78bf41b16f01d66 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_20.txt @@ -0,0 +1,17 @@ + + ddtoxxototdd + tcogctdtoooeeddpott + ttgdtpgxpg egdgotpetd + degdpep dtteo5poo + de tep d5gdeedpoeeo + 5etep tppceg5 poxeo + cecte tp -tpd toe5 + edd5e ggccegt exge + e5eectocoooooodtpcddoop 5 eo + pededg 5eeddddeo poogeoo t5 ee + otdopgggggggpg otoeete 5o + p dp5t d5p-e5 + oddptxd tcpecpp + dt--gxtdcctcgxget5pp + eptxdgoddddepgg + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_21.txt b/codex-rs/tui/frames/slug/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..87e3597d5d8fc8eff63c5920f15a59c7645a0be0 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_21.txt @@ -0,0 +1,17 @@ + + ddtotootottdd + ttpeeddtxoxtcde-ptd + cpddtpdge edxptdept + tpecgp dtcptcpt + 5t5pe te5do ooddt + 5g5e tppd55 oodt + epee dg5et5p ocog + eo oc get e e + e e ttcccccttt detget c5 e + e xo ed dte dgepet 5g-5 + c g- eeggg pe ppdtc 5e t + pt ccd dpg 5 + pd d-d d-cpp te + pod pgptcxxccopgg -e + pttctddddtdctpe + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_22.txt b/codex-rs/tui/frames/slug/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..8dfe7daaab686847d394b122eac88459929266c2 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_22.txt @@ -0,0 +1,17 @@ + + dttotootottd + dt5pe-d5e5oeecet5ptd + tcpcxoppe egpopptddtt + cpxppg tteeedpo + oex5p td5eoe-5cpe + oep5e tx5ecd5e oeooe + etpo5 xteop5p xe5e + eeoee eoexdo edege + eetgt txoocccottdopedgot decgg + deo pdootg5tgx55e opcdgettg5oo + ooode gggggppge ecptd555gte + opodot dptgetp + pgtc ttd d-ptgpd5 + pcpttgpxcotcc5opggptp + gxgxdcddd-od-ope + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_23.txt b/codex-rs/tui/frames/slug/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..f573acb7142bcb6155beb92e446a745992ed04b6 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_23.txt @@ -0,0 +1,17 @@ + + dttdotpttotdd + doeteodtdootedepetd + tgteptcpe gxcteoceet + 5cepc5e dccoeeco + 55ee5p t5ttpp5poeo + toeg5e dppppp5ppexoet + eeege 5c5- dee ggepp + x5e e egetdot e p5e + dgo etxcooooocoedpedgod e exe + eoodegog 5eo oedotx ode + 5pe e eggggggg pe5coed5d5e + teede- t5eod5e + o etp5dd d-gcp5t5 + oootcptoxoodttg-dtpe + gxgpooxddtddppe + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_24.txt b/codex-rs/tui/frames/slug/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..92833e8c589d34083db5f0a301e830d8769f5827 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_24.txt @@ -0,0 +1,17 @@ + + dxdcttpootd + tgd5geddxoottdpt + tgpco5tgx -ecpxppt o + tp55t5eotoex5egdpoeodp + t 55o5t egg5odeeeoetp + xtotoe ptt5g-ecg5go + e exg t5dt5g5doeoded + e oee eto5odexd5-eee + e- eccccttxxxttdptde e ee + d 5 gpe5ttcctte-dotpe epe + o poeopgggggge ot-poeo5te + o otpo egg5c5 + o-poee- dogd5cdp + --ptodgxxcoocedd5e + pcdptcoddootp + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_25.txt b/codex-rs/tui/frames/slug/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..d8b8655dacfc18dac5a4c41c489e44f45f7a0aad --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_25.txt @@ -0,0 +1,17 @@ + + dtopcttttd + tgptpedcoepeet + 5e55ttg-etoooeeed + etpe 5g oe goetpo5 + tddot5pdc5deg e55o5p + otxexdpt-dec 5ete55et + e5epe edd5od5eo5dgeoe + eee5e-ggxdoo5eodxoeeo + dtoegeddooootxeooetpeo + 5-ge5etedeecpdeopo5oe + oxp5oeegggggpt5eoe5ee + edgectco-tpcd5t55e5 + dededodpc5td5dee5 + o-coodpoeppgpep + xtgpottdtep + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_26.txt b/codex-rs/tui/frames/slug/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..4be73d44de00578edd20dda4e264e4d3d7be0ec3 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_26.txt @@ -0,0 +1,17 @@ + + dcpppgtt + 5pec5oetcet + 5pggecoeoggot + cpgteexdeedt5d + edtoe-xgpo5ceoc + o ge5c5gpdogoo5 + eg ppoee5eeccgt + 5- oeeeddoee5ee + e -opctxtoo5oee + g -de5teddtpoope + eeg5epgot5etoee + odxe5o 55geee + p peo de5e5t + egpoogpdppc + o5cdpxdop + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_27.txt b/codex-rs/tui/frames/slug/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..f333909d2b753a04f95a7ca4f7e5434af16b1183 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_27.txt @@ -0,0 +1,17 @@ + + cppptt + ecc5e5o + cpe pe5pe + exxdeecex + e eed-po + xd-dgeeeee + o geedpeeg + e eeexogee + e- -geteeee + po -gdedpee + e- ddppt5p + eetteed5e + eootot5ed + oddeoo55 + pog do5 + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_28.txt b/codex-rs/tui/frames/slug/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..3c0deb542c8a205606df4bffcd36de837685d8bb --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_28.txt @@ -0,0 +1,17 @@ + + 5ppc + etdee + o cee + e-epe + e xe + e -ge + dex de + e-gge + 5o de + ee-cxe + e de + eotoe + eopee + pdd5e + x -te + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_29.txt b/codex-rs/tui/frames/slug/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..0c6277f4d52f114a48a2af5cb76bf1a372a291c6 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_29.txt @@ -0,0 +1,17 @@ + + tppppt + tep5gpo + dtee pge + eeeedot5 + o xge e + etxee dd + eooeee d + eeoxe + eexpe e + deoee e + pee5ed o + e5xxe de + xeeeexde + eoep5gep + xep -t + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_3.txt b/codex-rs/tui/frames/slug/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..b1e91736085ba02e456abcdea5e200e7db41e4d9 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_3.txt @@ -0,0 +1,17 @@ + + d-octtooootd + dtt5oeetegooecddeptd + tc5pcepgge egxgppt5det + 5t5oecttd eopgeeo + p5pe5d5eepod odoeed + 5eoo5 -teocoo e ooe + op e ppoedget -p5et + t-ete t-eg5oe xdeoe + -gpe ceptxep-xottdddttdxdgce + tocot -5p5cce epeddtgo-tcoeeoee + pde e geettg gpppgggppt555t5 + ppx ot e 5dtd55 + od55pot ttc5gtep + odttdppdococtcopcedtg + eptpdcxddddxc5dg + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_30.txt b/codex-rs/tui/frames/slug/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..9dfd28bc20da00c349a4e038f5c672702da4068e --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_30.txt @@ -0,0 +1,17 @@ + + dcgpptt + d5ceeoppoo + gpdetooetto + eeoe5edoeo ot + opeeeoetet e + epedt peeee-e + o5eeeod o5oe oe + ge ptoxtege- e + gedgoxoxo5et e + geeoeddxegtt e + goeecodpxeetxe + ep55t5poeeg e + oeoee5pxetx5 + tdttod5et5p + o--edddcp + eeee \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_31.txt b/codex-rs/tui/frames/slug/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..1dba8edd8f769e7018c1869875f0e9c713b78020 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_31.txt @@ -0,0 +1,17 @@ + + d-cptptot + dtpcttdgtoppt + teo55tode-gedpo + tx5tddpcdtooeoxeo + deeeet-podtgoe5dd + cedexdepgocpt-5etge + 5ee edpeo-o5cpepe5e + oot exgeo edggexo-e + eotdepdxex5txxed e + geex55eedddodeoee p + dpd5tet 5pppe5epxdg + eeeooot dop e + cepodgt - epe5e + ceoe5deetegtee + pgoxdtp5-cp + eeee \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_32.txt b/codex-rs/tui/frames/slug/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..33160e71634b635b7127a281179cab25366eae59 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_32.txt @@ -0,0 +1,17 @@ + + tttttccxtd + cpxppxoeeppext + 5dpodttdc-epepoppt + p55edtec- - tdoettgt + 55etepoppec petxo5opot + o5ootopeeceetd et5 p + pegctepoeettetoe5d55epd + eeetd gdeeg5pec-dgoegge + oee-d pdegddetttxxxoegoe + pggdeepopodddddeepecx-d + topee5 epggpppdc555-5 + otedoo toe5px + pppeextd d5de5 t + o-ogxtecopedtgd5 + xcoddtt5pdgg + eee \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_33.txt b/codex-rs/tui/frames/slug/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..ff8827f3d2f385367b3140b984bc47cbc38ccb67 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_33.txt @@ -0,0 +1,17 @@ + + dttototox-d + dtpeeopoxce-epep- + d5oetpttoge gp5eetgt + to5e5detd peeodp + d-e5eee5odo etood + 55t odooeot 5o5do + eet g otpedeo epee + pett tppo5ot ogep + eee e te 5ptttcootxxt5deox + d5epg5ct5exedddddeepdddxe- + ooot e5e5 5 ggggppp eet5de + otoptgt 55c5o5 + pogptpct dtet5pop + oxgpotetctdgctopxp + goottddddtpc-g + eeee \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_34.txt b/codex-rs/tui/frames/slug/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..4b1eb6a5a235c808d4c47b62e47c518b316207c8 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_34.txt @@ -0,0 +1,17 @@ + + dtottttoxtd + dtpt5ocegxtpoppctod + d55tepgcxpe edootgppt + t5depd5td petpoo + 55eo-5egeggt oopod + t5oee5 oogeopo otoeo + epdxd podpedd ecxd + oeogc epde5ge 5 do + tp5-g 5o55gdoottttttxddg xde + eeot5ttdg55pxeedx-dddt5deeeeo + 5dot eoepdg gppeeeed t5toep + 5tocood 5c-e5p + oteetoct dtogd5te + -o5xgettcttopopetpcp + gxocodddddcopod + eee \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_35.txt b/codex-rs/tui/frames/slug/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..f2432dc0adf3d221e20f9acb778a89370507bece --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_35.txt @@ -0,0 +1,17 @@ + + dttcotttottd + tpop-xpptgepxcgxpod + d55ectpedpge egpop-gept + t55t5tctd ptdoed + 55xe55o gopt eopet + c5c5te pedg--pd eooe + o g-5 oo ppd- edoo + xexc 5ooc5p 5 g5 + 5 td tee5cg5eoodcdotxx exop + etdcp 55cgpt5ec otdddd5gx5p ep + 5eotp5ode5de egddddpddp55dte + g-do5x d5p td + ocedctct tc5dppp + gddgxpx-cxxtxc5pgd5cg + gdxcodd5ddttpgd + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_36.txt b/codex-rs/tui/frames/slug/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..c84a104e4ac21190658996622103b774df34131f --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_36.txt @@ -0,0 +1,17 @@ + + ddtottttottd + doggot5c5totcttgpptd + topottp-pgee egpxptetpet + degptdddd ppxoge + t5dcopeoeot- do-p + 5 t5e pd ge5t godp + e cge go goo edet + eeox do d55g oe e + epge 55 tpgptttdtttttd eoxe + dpeo tedd5x5 gexdddddddee o5pe + p peo tdt5d gppdddddg etg5 + ptgoc- t5eg5 + o5eetxt tttg5te + ptdgppodcxdtxcg-gtctp + ept5xdttdttttppg + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_4.txt b/codex-rs/tui/frames/slug/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..2eed2c846535ea5b541d3df4dd18c25c22b866cc --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_4.txt @@ -0,0 +1,17 @@ + + d-octootottd + d-gtpe5geoo5pceg5ptd + tpoeeetpge edxodpe5ood + 55eteg-tt geppopo + 5e5deoeocdet ogoepo + 5eede pdee5po p ooet + goece pppotget t gce + e-o te5pdee eee + deged ce5gd55txtttddddtt eep + eo5ge t555tdeeooet-dtc5dce55ge + eecpotooto5 gppppppppd-eeee + teogede tdc5ppp + ootcdgtd d-dtpce5 + xeeodppoccocttoptoepe + pooxcxdddddc-pe + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_5.txt b/codex-rs/tui/frames/slug/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..e0c7693a9ecad6b6cfe232fc14e62c510a7b887a --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_5.txt @@ -0,0 +1,17 @@ + + d-occtooottd + dtgt5p5eetxotcdegtt + pd5otepge gdoepogtpt + te5e5e-ot -deeeed + to5p5ddtedot e oeoed + xeee5 odeoc5ed t5ee + e5egt eodeoget ppxtet + e5edg tdoe5de tede + etedp 5degtepodxtxdddttdge-e + doeott5tpg egg55oodto-t5e5pee + oteetxtoo5te gppgggggtxceo5 + oot5oo e 5d5ptd + d5poeotd dottppcp + depetptocxodttopdxcp + d-pdpgddd-dttpe + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_6.txt b/codex-rs/tui/frames/slug/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..d5ac091f39ce0413917dd2d6b4300205003745a5 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_6.txt @@ -0,0 +1,17 @@ + + d-occtoottdd + tdc5peoptettcdtptd + ttteeepg egx-optp5od + 5tepepdot oteeeet + poeeepootpo -ooeot + c-5e5edtceodet -oo5e + d txe gdoe5do dtede + x exe deox5ee xtoee + d-e-e 5peepc5tdxtxddttd-o5e + eeot5dddct e5opteetcoooeteog + 5 e 5xeec5g gpgggppgeoo5e + t c 5te tcd55ee + -eodppt dtdd5ptt + egxptgtgcxddttppgccp + d-cpttdddedttp + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_7.txt b/codex-rs/tui/frames/slug/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..02d1f1ae521bdf17fe1ae7f7f1fc00721bb688d9 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_7.txt @@ -0,0 +1,17 @@ + + d-xcptoottd + tpetoetptooocgept + p teeepe edxegp5dpt + ooeeexct dxope5t + epepeede5ot - peeo + e ceeg epoceo t-eee + eoge ddeoeoe ppdee + dg5xe ot5epee p eee + 5gxp tgd5eo5xxccdxxocpoee + etpeeocxe5pe-oeeoototcoeoe + o od5pepd5 ppppppggd5ee + pd 5d5de tg-5c5p + pcttoood tdxtp5pp + odpoepocxdddtpept5 + gxpgxcxddtdcpp + \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_8.txt b/codex-rs/tui/frames/slug/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..d028ab360ee04ab9b1e0924394734dc232a409ea --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_8.txt @@ -0,0 +1,17 @@ + + ddtdcttottd + tgp5eeepoogx gt + deote55pgtgx5xpotdt + dpop5ette p odeeo + p 5e5toe5o -poeet + epteodddoedp oteee + epeee pdcoeee ede5et + otee5 eto5etp- -e5ee + o oeedd g5poex5tttttoxee + g op5odegteteeceoddeeop + 55ooeoeee5pdpdpgopg5oe + ptgeoeee -t555p + podpoood d5odet5p + -cpetpgcctpc5otee + xxdppoedtt5oe + ee \ No newline at end of file diff --git a/codex-rs/tui/frames/slug/frame_9.txt b/codex-rs/tui/frames/slug/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..2481e07a3576548651ad6333c1ff961825197bd9 --- /dev/null +++ b/codex-rs/tui/frames/slug/frame_9.txt @@ -0,0 +1,17 @@ + + -odp5ttot + 5pd5eepgogd-od + 5 tee5pddo5godxt + g ee5cod ddteodett + 5pgcopgept p-ptctee + e eeegdeocdd epepeee + e xpoeppeootg-t5 eee + e x5dtoxeed5oode gee + g gteg 5egexxetexteee + edeeedtededeeeeddeee + g ed5ooe5gppppeeg5oe + oxeeote t5 d5ee + otoeoo 5cdce5p + d-godep5toccpee + p-d pooect5g + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_1.txt b/codex-rs/tui/frames/vbars/frame_1.txt new file mode 100644 index 0000000000000000000000000000000000000000..0ca3a5d334c207ac2b627d3da999d130c192b88b --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_1.txt @@ -0,0 +1,17 @@ + + ▎▋▎▋▌▉▉▌▌▉▊▎ + ▎▌▊▋▉▍▉▋▉▍▌▏▏▌▎ ▎█▉▎ + ▊▏▉▏▉▉▉█▍▎ ▎█▉▎█▏▌▏▏▏▉ + ▌▉▎▍▉█▊▊▎ ▋▉▏▌▏▊ + ▍▍▌▋█▍▏▍▎▍▍ █▋▏▍▍▊ + ▏▉ ▉▎ ▏▉▋▌▏▏▊ █▋▍▏▏▊ + ▉▍█▊▉ █▍▏▉▋█▏▎ ▏▋▏ + ▏▏▎▏▎ ▊▋▋▏▌▏▉ █▎▏ + ▏▌▏█▎ ▌▏▏▍▍▏█▋▏▉▉▉▉▉▉▎▉▊ ▌█ ▏ + ▎▏▌▉ ▎▌▉▎ ▋▉ ▏█▏▎▎▎▋▋▊▊▊▏▋▊▋▊▏ + ▍▍▎█▍ ▍▍▊▋▋▎ ▎▍▉██▉ ▍▉█▋▊▌▎▋ + ▉▍▊ █▊ ▎ ▊█▋▉▎▏ + ▍▍▊▎▍▉▎ ▎▌▎▉▏▎▉█ + ▍▉▊▍▎ ▉▉▋▌▌▌▌▋▌▉▉▎▊▏▉ + ▎▉█▉▏▏▏▎▎▎▊▎▌▉▉█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_10.txt b/codex-rs/tui/frames/vbars/frame_10.txt new file mode 100644 index 0000000000000000000000000000000000000000..b422fb1274eed0fbdf824bacab23062d29433718 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_10.txt @@ -0,0 +1,17 @@ + + ▎▉▉▉▉▍▉▉▎ + ▉█▎▉▉▉▍▏▋▎▉▊ + ▍▎▊▏▏▋▏▏▊▏▉▍▊▊ + ▏▎▎▋▍▏▌▎▋▍▊██▍▍▊ + ▋ ▌▏▏█▏▍ █▊▋▎▋▏▋ + ▏▎ █▏▉▏▋▋▍ ▎▎█▏ + ▍ ▏▉█▉▏▏▏▏▉ ▏▋▊ + █ ▏▏▍▉▋▉▏▏▊▎▎▋▍▏▏ + █ ▏▍ ▍▍▏▌▏▏▉▍▉▌▏▏ + ▏ ▊▏▍▊▏▎▋▎▉▎▎▎▏▉▎ + ▊ █▏▏▏▏▏██ ▍▊█▍▏▎ + ▍▎█▊▍▊▉█ ▎▏██▊▋▋ + ▏█ ▏▏▏▏▉▊▋▊▋▏▋▎ + ▌▎▉▉▋▏▉▌▎ ▋▋█ + ▉▊ ▎▉▍▎▊▌▉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_11.txt b/codex-rs/tui/frames/vbars/frame_11.txt new file mode 100644 index 0000000000000000000000000000000000000000..5d4524e29385d7c8f5112eb4a2734f32b687f4ae --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_11.txt @@ -0,0 +1,17 @@ + + ▊▉▉▉▉▉▉▎ + ▋█ ▌▏▏▏▍▍▊ + ▋█▎▎▏▏▌▍▉▋▍▊ + ▏▎▎▏▏▌▌▊▎▌▋▋ + ▎█▋▌▍▏▉▍█▌▎▏▏▊ + ▏▉▎▏▏▉▎▌▌▏▎▋▏▏ + ▉▉ ▊▏▏▏▏▏▏▎▏▌▍ + ▏ ▏▏▋▏▏▍▋ ▏█▏ + ▋ █▏▋▏▏▉▋▉▏▎▏ + ▉▉ ▎▏▋▌▊█▋▏▎▏▍ + █▎▊ ▏▏▏▏▌▋▏▍▍▏ + ▍▎▊▋▏▎▉▋▉▉▌▏▎ + ▎ ▉▏▍▍▍▎▏▌█ + ▍▉ ▉▏▏▊▎▋▉▎ + ▍▎ █▉▉▎▉▎ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_12.txt b/codex-rs/tui/frames/vbars/frame_12.txt new file mode 100644 index 0000000000000000000000000000000000000000..f81900edb1cdb0d93e6ce8ec1562536fe3a03be1 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_12.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▋ + ▊▌▎▎▋▏▊▍ + █ ▏▋▍▉▊ + ▋▉▏▏▏▌▌█▏ + ▏ █▏▏▏▏▋▋▏ + ▏█▋▋▉▍█▎▏█ + ▏ ▏▋▍▏▏▍ + ▏ ▋▉▍▏▏█ + ▉▊ ▏▋▏▏▉▏▏ + ▏ ▋▏▉▌▉▎ + ▏▋ ▏█▌▉▉▋ + ▊ ▉▏ ▎▊▏ + ▋ ▉▉▏▊▉▍▏ + █▍▋█▊▏▍▏▎ + ▍ █▏▍▋▋ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_13.txt b/codex-rs/tui/frames/vbars/frame_13.txt new file mode 100644 index 0000000000000000000000000000000000000000..4231032a45cce91fbb447d8715b7ffcb43311b6d --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_13.txt @@ -0,0 +1,17 @@ + + ▋▉▉▉▊ + ▏▎▎▏▏ + ▏▎▎▏█ + ▏▉▉▏▎ + ▉ ▏▏ + █▋▊▏▏ + ▊ ▏▏ + ▉ █▏ + ▋▉ ▎▏▋ + ▏█ ▉▌▏ + █▎▋▋ ▏▎ + ▏▎▎▎▏ + ▏▉▊▏▎ + ▉▎▎▏▌ + ▊ ▋█▌ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_14.txt b/codex-rs/tui/frames/vbars/frame_14.txt new file mode 100644 index 0000000000000000000000000000000000000000..6eab794e0ab2a6ee4ffe2b146b6d8c0957738286 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_14.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▌ + ▊▋▎▎▋▍ + ▏▎▏▏▋ ▏ + ▏▏▋▏█▍▏▊ + ▏▏▏▍ ▏ + ▏▉▏▏ █▏ + ▏▏▏▏ ▋ ▉ + ▏▏▍▏▎ ▍ + ▏▏▏▏ █ + █▍▋▋ ▌▋ + ▍▏██▊▋▊▏ + ▏▉▏▏▎▎▎▏ + ▋ █▏▌▌▌▎ + ▏▍▋▉▎▎▋ + ▎▉ █▌▋ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_15.txt b/codex-rs/tui/frames/vbars/frame_15.txt new file mode 100644 index 0000000000000000000000000000000000000000..fa9a859bd04cef883b5ec69340f8c9a877035e60 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_15.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▏▎ + ▎▊▌▏▏▍▋▉▊ + ▋▋▏▉▌▍▏█▉▏ + ▏▋▎ ▋▋▊▋▎▎▏ + ▏▏▌█▌▍▏▏▋█▍▏ + ▍▏▏▊▏▏▋▋▏ █ + ▏▏ ▏▏▏▏▏▏▊▏ + ▏▏▋▏▏▉▏▏▏ █▊▏ + ▏▏▉▊▊▉▏▏▏▎▋▋▏ + ▏▎▏▌▋▏▍▏▉▋ ▋▏ + ▏▋▏▏▏▏▎▏ ▎ ▌ + ▏▉▉▋▍▏▏▍▊ ▌ + █▏▍▊ ▋▋▋▎ ▌▎ + ▏▍▏▏▍▍▋▉▍▋ + ▍▍▉▍▎▎▎▋ + ▎▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_16.txt b/codex-rs/tui/frames/vbars/frame_16.txt new file mode 100644 index 0000000000000000000000000000000000000000..1fcc2090a2135c1e1484fbe30bde98d710958777 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_16.txt @@ -0,0 +1,17 @@ + + ▎▌▉█▉▉▉▏▎ + ▋▍▏▏▉▏▏▍▏▌▍ + ▏▋▋█▍▋▌▍▏▏ █▍ + ▋▋▋ ▌▉▎▍▏▍▏▏▉▉▏ + ▏▏▌▎▋▉█▌▏▏▉▋▏▎▍ + ▏▏▏▉▏█ ▏▍▌ ▏ ▏▋▏ + ▍▍▏ ▋▎▊▏▏▏▏▋▉█▊ ▏ + ▏▋▌ ▏▎▏▏▋▏▏▏█▌ ▎▏ + ▍▍▏▏▏▉▏▋▍▏▏█▉▉ ▋ + ▉█▎▎▊▌▌▍▉▏▋▎▏ ▊▎▏ + ▏▏▏▉██▋▉▍▏▌█▌▋▏▏▎ + ▏▏▉ ▏▎ ▎▋▋▊ ▋ + ▍▏▉ ▏ ▋▊▋▎▊▏ + ▍▏▏█▎▉▏▋▋▉▎▏ + █▋▋▎▌▋▎▎▌▉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_17.txt b/codex-rs/tui/frames/vbars/frame_17.txt new file mode 100644 index 0000000000000000000000000000000000000000..1adf01af903f789755d501ca21b84a6f4470529b --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_17.txt @@ -0,0 +1,17 @@ + + ▊▌▉▉▉▌▌▏▊▎ + ▎▌▉▉▏▏▏▉▌▉▏▊█▍▊ + ▊▉▍▋▎▌▌▎▉▊█▏▏▏▎█▍ + ▋▏▋▎▋▏█▎▎ █▏▌▍▏▏▏▌▍ + ▏▋▎ ▏ ▏█▋▍▌▍▋▋ ▊ + ▎▌▉▊▋▎ ▊▋▋▏ ▋▊▌▋▏▋▋ + ▉ ▌▋▏ ▊▋▋▏▎ ▉█▏▏▋▌▏ + ▊█▌▏ ▋▋▏▏▋▏▊▏▏▌▌▉ + ▊▋▉▋▍▌▌▉▏▍▎▏▍▍▋▍▏▏▏ ▏ + ▏█▎▌▎▎▏▋▉▍▋▏▏▍ ▍▉█▉▍ + ▍▍▍▋███▉▉▉▉▍▎▏▉▋▋▍ ▍█ + ▍▏▏▎▉ ▎▋▏▏▎ ▌ + ▎▍▍ ▉▍▎ ▊█▊▋▍▎▋ + ▊ ▏▉▉▌▍▉▎▎▌▉▋▏█ + ▎▉▌▋▎▊▉▏▉▎▉▉ + ▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_18.txt b/codex-rs/tui/frames/vbars/frame_18.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c46c648214f10005d2c947def19017a467745c4 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_18.txt @@ -0,0 +1,17 @@ + + ▎▌▌▉▌▌▉▌▏▊▎ + ▎▉█▎▌▊▉▉▏▎▎▊█▉▌▌▎ + ▎▋▋▏▉█▉▌█▊█▋▎█▉▋▋▍ ▉ + ▊▏▏▉▋▊▍█▉ █▍▎▎▋▌▏▍▌▍▍▍▊ + ▎▍▋▋ ▋▏ ▎█▉▎▍▋▏▋▏▎▍▌▍▍▎ + ▋▍▏ ▊▊▏▋▎▎▎▋▊▉▊▏█▊▋▍▋▎▋▏ + ▏▏▍▋▋ ▉▋▉▋▏█▎▋█▉▏▌▏▌▊ + █▍▊▏▏ ▍▍█▏▏▌▍ ▏▌▏█ + ▍██ ▊▍▌▌▌▌▌▉▏▏▊▏▉▋▍▊ ▏▉▏▎ + ▎▍▍▏▉▍▉ ▏▏▋▎▎▍▎▏▏▏▎▌▏▎▍ ▊ + ▏▋▏▍▎██▉▉▉▉▉▎ ▍▎▎▋▏▋▉▋▎▊▎ + ▍▍ ▊▍▊ ▎▎▉█▋▉▎█ + ▍▍▉▉▍▍▋ ▎▉▍▎▋▍▊▎ + █▏▋▉▉▉▉▌▌▌▍█▉▎▋▎▊▉ + █▏▏▎▋▎▎▊▋▉█▌▉█ + ▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_19.txt b/codex-rs/tui/frames/vbars/frame_19.txt new file mode 100644 index 0000000000000000000000000000000000000000..572f5ffc3242ac988a41a8fe68da6905db56c960 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_19.txt @@ -0,0 +1,17 @@ + + ▎▎▌▌▉▏▉▌▌▏▋▎ + ▎▉▏▏▏▍▋▌▌▌▎▉▎▊▏▉▊▊ + ▊▉▌▌▋█▏▉▉▎ ▎▉▋▊▎▉▌▍ + ▋▌▏▉▊▍▎ ▎▌▌▉▊▍▏▉▊ + ▏▏▋▎▋▎ ▊▏▍█▋█▏▍ ▏▍▊ + ▏▍▋▉█ ▎▏▉▏▋ ▋▋ █▋▉▏ + ▍▌▏▏█ ▋▉▍▋▏▉▊▎ ▋▋▋▉▏ + ▏▏▏▏ ▏▌█▍▏█▊ ▏ ▏▏▏ + ▏▉▏▍▊▌▌▌▌▌▌▏▏▏▏▉▉▌▉▏▌ ▍ █▏▏ + ▍▌▋▊▏▌▉▋▍▉▉▍▊▎▊ ▍▎ ▋▍▎▍▊▋▋▏▎ + █▍█▋▉▏█████▉▉█ ▍▍▎▋▏▋▋ ▋▋█ + ▍▍▍▉▎▍ ▎▋▋▎▋▋█ + █ ▎▍▏▏▉▊ ▊▌▌▋▋▏▌▎ + ██▋▏▉▋▏▉▎▎▌▌▋▉█ ▌▉▏█ + █▏▋▎▋▊█▎▊▋▍▉▊▍ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_2.txt b/codex-rs/tui/frames/vbars/frame_2.txt new file mode 100644 index 0000000000000000000000000000000000000000..0e0c021f436b5f348f96804ef68ea5fffcf148c7 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_2.txt @@ -0,0 +1,17 @@ + + ▎▋▎▋▌▉▌▌▌▉▊▎ + ▎▉▊▋▉▌▉▏▏▏▌▏▏▌▎█▏▉▉▎ + ▊▏▉▏▍▉▉▏▉▎▎ ▎█▉▌▉▋▏▏▌▏▊ + ▎▏▋▎▋▉█▊▊▎ ▊▊▍▏▋▏▊ + ▊▍█▋▉▍▏▍▎▍▍▎ ▍▊▏▍▏▊ + █▋▉█▎ ▏▉▍▉▋▍▉ ▍▍▏▉▏ + ▊▏█▉▏ ▍▋▏▌▏▎▏▊ ▋█▏█▏ + ▏█ ▍▎ ▊▏▉▏▏▌▉ ▏█▋▋▏ + ▉██▌▏ ▌▏▍▍▎▏█▋▏▉▉▉▉▉▉▉▉▊▏▎▏▏▉ + ▎▌█▏ ▋▏▏█ ▋▉ ▏▌▍▎▎▎▎▋▋▎▎▏▉▋ ▏ + ▍▍▍ ▉ ▉▍▋▋▏█ ▎█▉▉▉▉▉▉▉█▏▊▉▏▏ + █▍▎▋▋▊ ▎ ▊▉▌▋▊▉ + ▋▍▎▎▏▉▊ ▊▌▎▉ ▎▏█ + ▎▏▍▌▎▎█▉▉▋▌▌▌▌▋▌▉▎▎▏▏▉ + ▎▉▉▉▉▏▏▎▎▎▎▏▌▋▉█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_20.txt b/codex-rs/tui/frames/vbars/frame_20.txt new file mode 100644 index 0000000000000000000000000000000000000000..42c288df9295e849b53e96aeaf7cf2f39a8ff3e4 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_20.txt @@ -0,0 +1,17 @@ + + ▎▎▉▌▏▏▌▉▌▊▎▎ + ▊▌▌█▌▊▎▊▌▌▌▏▏▎▍▉▍▉▊ + ▊▊█▎▉▉█▏▉█ ▎█▍█▍▉▉▏▉▎ + ▎▏█▎▉▏▉ ▎▊▉▏▍▋▉▍▍ + ▎▏ ▊▏▉ ▎▋ ▎▏▏▍▉▍▏▏▍ + ▋▎▊▏█ ▉▉█▌▏█▋ █▍▏▏▍ + ▌▏▌▉▏ ▊▉ ▋▉▉▍ ▊▍▏▋ + ▏▎▍▋▎ ██▌▋▏█▊ ▏▏█▏ + ▏▋▏▏▌▊▌▌▌▌▌▌▌▌▎▊█▌▍▍▍▍▉ ▋ ▏▍ + █▏▍▏▎█ ▋▎▎▎▎▎▎▏▍ ▉▍▍█▏▌▍ ▊▋ ▏▏ + ▍▊▍▍████████▉█ ▍▊▌▏▏▊▏ ▋▍ + ▉ ▍▉▋▊ ▎▋▉▋▏▋ + ▍▎▍▉▊▏▎ ▊▌▉▎▌▉█ + ▍▊▊▋█▏▉▍▌▌▊▋█▏█▎▊▋▉█ + ▎▉▉▏▎ ▌▎▎▎▎▏▉██ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_21.txt b/codex-rs/tui/frames/vbars/frame_21.txt new file mode 100644 index 0000000000000000000000000000000000000000..aa5d4f7274c7108e2961c552c10309fdf99346d4 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_21.txt @@ -0,0 +1,17 @@ + + ▎▎▊▌▉▌▌▉▌▉▊▎▎ + ▊▉▉▎▎▎▎▉▏▌▏▉▌▎▎▊▉▉▎ + ▌▉▍▎▉▉▍█▎ ▎▍▏▉▉▎▎▉▊ + ▊▉▏▌██ ▎▊▌▉▊▌▉▊ + ▋▊▋▉▎ ▊▏▋▎▍ ▍▍▎▍▊ + ▋█▋▏ ▊▉▉▎▋▋ ▍▍▍▊ + ▏█▏▎ ▎ ▋▎▊▋█ ▍▌▍█ + ▎▍ ▍▌ █▏▊ ▏ ▏ + ▏ ▏ ▊▉▌▌▌▌▌▉▉▉ ▍▏▊█▏▊ ▌▋ ▏ + ▏ ▏▍ ▏▎ ▎▊▏ ▍█▎▉▏▊ ▋█▋▋ + ▌ █▋ ▎▎███ █▎ █▉▎▊▌ ▋▎ ▊ + █▉ ▋▌▎ ▎▉█ ▋ + ▉▎ ▍▊▎ ▎▋▌▉█ ▊▎ + ▉▌▎ ▉█▉▉▋▏▏▌▋▌▉██ ▊▎ + ▉▉▉▌▉▎▎▎▎▊▎▌▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_22.txt b/codex-rs/tui/frames/vbars/frame_22.txt new file mode 100644 index 0000000000000000000000000000000000000000..3b1ce4ecdedb51f46d0f3346dcd773609f87266e --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_22.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▉▌▌▉▌▉▊▎ + ▎▉▋▉▏▋▎▋▏▋▍▏▏▌▏▉▋▉▉▎ + ▊▌▉▌▏▍▉▉▎ ▎█▉▍▉▉▉▎▎▉▊ + ▌▉▏▉▉█ ▊▊▏▏▏▎▉▍ + ▍▏▏▋█ ▊▎▋▏▌▏▋▋▌█▏ + ▍▏▉▋▎ ▊▏▋▏▌▍▋▎ ▍▏▍▌▏ + ▏▉▉▍▋ ▏▉▏▍▉▋█ ▏▏▋▏ + ▏▏▌▏▎ ▏▍▏▏▍▍ ▏▍▏█▏ + ▏▏▊█▊ ▉▏▌▌▌▌▌▌▉▉▎▍▉▏▎ ▍▊ ▎▏▌█ + ▍▏▌ ▉▎▍▍▊ ▋▊█▏▋▋▏ ▍▉▌▎█▏▊▊ ▋▍▌ + ▍▍▍▎▏ █████▉▉█▎ ▎▋▉▉▎▋▋▋█▊▎ + ▍▉▍▎▍▊ ▎▉▊█▏▊█ + ██▉▌ ▉▉▎ ▎▋▉▊█▉▎▋ + █▋▉▉▊█▉▏▌▌▉▌▌▋▌▉███▊▉ + █▏█▏▎▌▎▎▎▊▌▎▋▌▉▎ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_23.txt b/codex-rs/tui/frames/vbars/frame_23.txt new file mode 100644 index 0000000000000000000000000000000000000000..0b99396129de947b04ab2c81b52284f3a64f4edc --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_23.txt @@ -0,0 +1,17 @@ + + ▎▊▉▎▌▉▉▉▉▌▉▎▎ + ▎▌▏▉▏▍▎▊▎▌▌▊▏▎▏▉▏▉▎ + ▊█▉▏█▊▌▉▎ █▏▋▉▏▍▌▏▏▊ + ▋▌▏▉▌▋▎ ▎▌▌▍▏▏▌▍ + ▋▋▏▎▋█ ▊▋▊▊█▉▋▉▍▏▍ + ▊▍▏█▋▎ ▎▉▉▉██▋▉█▏▏▍▏▊ + ▎▏▏█▏ ▋▋▋▋ ▎▏▎ █ ▏█▉ + ▏▋▏ ▏ ▏█▏▊▎▍▊ ▏ ▉▋▏ + ▍█▍ ▏▉▏▌▌▌▌▌▌▌▌▏▎▉▏▎█▍▎ ▏ ▏▏▏ + ▏▍▍▎▏█▌█ ▋▎▍ ▍▏▎▍▊▏ ▍▎▏ + ▋█▏ ▏ ▎███████ █▎▋▌▌▏▎▋▍▋▎ + ▊▏▏▎▏▋ ▊▋▏▍▍▋▎ + ▍ ▏▊█▋▎▎ ▎▋█▌▉▋▉▋ + ▍▌▍▉▌▉▉▍▏▌▌▎▉▉█▊▎▉▉▎ + █▏ ▉▌▍▏▎▎▉▎▎▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_24.txt b/codex-rs/tui/frames/vbars/frame_24.txt new file mode 100644 index 0000000000000000000000000000000000000000..5e26d7a27bf8238c9502be22e35d1d1140909bf0 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_24.txt @@ -0,0 +1,17 @@ + + ▎▏▎▌▉▉▉▌▌▊▎ + ▊█▎▋█▏▎▎▏▌▌▉▊▎█▉ + ▊██▌▍▋▉█▏ ▋▎▋▉▏▉▉▊ ▍ + ▊█▋▋▊▋▏▌▊▍▎▏▋▎ ▎▉▍▏▍▎█ + ▊ ▋▋▍▋▊ ▎██▋▍▎▏▎▎▍▏▊█ + ▏▊▍▉▍▎ ▉▊▊▋ ▋▏▌ ▋█▍ + ▏ ▏▏ ▊▋▎▊▋█▋▍▍▏▍▍▏▍ + ▏ ▍▏▏ ▏▊▌▋▍▎▏▏▎▋▋▎▏▏ + ▏▋ ▏▌▌▌▋▉▉▏▏▏▉▉▎▉▊▎▏ ▏ ▏▏ + ▎ ▋ ▉▏▋▊▊▌▌▊▊▏▋▍▍▉▉▏ ▏▉▏ + ▍ █▍▏▍▉██████▎ ▍▊▋▉▍▏▌▋▊▎ + ▍ ▍▊▉▍ ▏ █▋▌▋ + ▍▋▉▍▎▏▋ ▎▌█▎▋▋▎▉ + ▋▊▉▊▍▍█▏▏▋▌▌▌▏▍▎▋▎ + ▉▋▎▉▉▌▍▎▎▌▌▉▉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_25.txt b/codex-rs/tui/frames/vbars/frame_25.txt new file mode 100644 index 0000000000000000000000000000000000000000..5009b8b66d259dfe97d1312e222a178ac8c325c8 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_25.txt @@ -0,0 +1,17 @@ + + ▎▉▌▉▌▉▉▉▉▎ + ▊ ▉▊▉▎▎▌▍▏▉▏▏▊ + ▋▏▋▋▊▊█▋▏▉▍▌▍▏▏▏▎ + ▏▉▉▏ ▋ ▌▏ █▍▏▊▉▍▋ + ▊▎▎▍▊▋█▍▌▋▎▏█ ▏▋▋▍▋▉ + ▍▊▏▏▏▎▉▉▋▍▏▋ ▋▏▊▏▋▋▏▊ + ▏▋▏▉▏ ▏▎▎▋▍▎▋▏▍▋▎█▏▌▏ + ▏▏▏▋▏▋█ ▏▎▌▍▋▏▍▎▏▌▏▏▍ + ▍▉▍▏█▏▎▎▌▌▌▌▉▏▏▍▍▏▉▉▏▍ + ▋▊█▏▋▏▊▏▎▏▏▌▉▎▏▍▉▌▋▍▏ + ▍▏▉▋▍▏▎██████▉▋▏▍▏▋▏▎ + ▏▎█▏▌▉▌▍▊▊▉▋▎▋▊▋▋▏▋ + ▍▎▍▏▎▍▎▉▌▋▊▍▋▎▏▏▋ + ▍▋▌▍▍▎▉▌▏▉▉ ▉▏▉ + ▏▊█▉▍▉▊▎▉▏▉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_26.txt b/codex-rs/tui/frames/vbars/frame_26.txt new file mode 100644 index 0000000000000000000000000000000000000000..900a51c3b55b886253863461b3128c807a024230 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_26.txt @@ -0,0 +1,17 @@ + + ▎▌▉▉▉█▉▊ + ▋▉▏▌▋▍▏▉▌▏▊ + ▋▉██▏▌▍▏▍██▍▊ + ▌▉█▊▏▏▏▎▏▏▎▉▋▍ + ▏▎▊▍▏▋▏█▉▍▋▌▏▍▌ + ▍ █▏▋▋▋█▉▎▍█▍▍▋ + ▏ ▉█▌▏▏▋▏▏▌▌█▉ + ▋▊ ▍▏▏▏▍▍▌▏▏▋▏▏ + ▏ ▋▍▉▌▉▏▉▌▌▋▍▏▏ + ▋▎▏▋▊▏▎▎▊▉▍▍▉▏ + ▏▎█▋▏▉█▍▊▋▎▉▍▏▏ + ▍▎▏▏▋▍ ▋▋█▏▏▎ + ▉ ▉▏▍ ▍▎▋▏▋▊ + ▏█▉▍▍█▉▎▉▉▌ + ▍▋▋▍▉▏▎▍▉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_27.txt b/codex-rs/tui/frames/vbars/frame_27.txt new file mode 100644 index 0000000000000000000000000000000000000000..0b2e8c7306f4ff438e2f64fd01df8fcdaac78c7e --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_27.txt @@ -0,0 +1,17 @@ + + ▌▉▉▉▉▊ + ▏▌▋▋▏▋▍ + ▌█▎ ▉▏▋█▏ + ▏▏▏▍▏▏▌▏▏ + ▏ ▎▏▎▊█▌ + ▏▎▋▎█▎▏▏▏▎ + ▌ █▏▏▎▉▏▏ + ▏ ▎▏▏▏▌█▏▏ + ▏▋ ▋ ▏▉▏▏▏▏ + █▌ ▋█▎▏▎▉▏▏ + ▏▊ ▎▍▉▉▉▋█ + ▏▏▉▉▏▏▎▋▏ + ▏▌▌▉▌▊▋▏▍ + ▍▎▎▏▍▌▋▋ + █▍█ ▍▍▋ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_28.txt b/codex-rs/tui/frames/vbars/frame_28.txt new file mode 100644 index 0000000000000000000000000000000000000000..01ce82b6d3cc3a6cf092c71544502d3fa6460d3b --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_28.txt @@ -0,0 +1,17 @@ + + ▋▉▉▌ + ▏▉▎▏▎ + ▌ ▌▏▎ + ▏▊▎▉▏ + ▏ ▏▏ + ▏ ▊█▏ + ▍▏▏ ▎▏ + ▏▊██▏ + ▋▍ ▎▏ + ▏▏▋▋▏▏ + ▏ ▎▏ + ▏▌▉▌▏ + ▏▌▉▏▏ + ▉▎▎▋▏ + ▏ ▋▉▏ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_29.txt b/codex-rs/tui/frames/vbars/frame_29.txt new file mode 100644 index 0000000000000000000000000000000000000000..c682a6082c1a7a6562cf939dbe09b1b305dabf05 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_29.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▊ + ▊▏▉▋ ▉▍ + ▍▊▏▏ ██▏ + ▏▏▏▏▍▍▊▋ + ▍ ▏█▏ ▏ + ▏▊▏▏▏ ▎▎ + ▏▍▍▏▏▏ ▍ + ▏▏▌▏▏ + ▏▏▏▉▏ ▎ + ▎▏▌▏▏ ▎ + ▉▏▏▋▏▍ ▌ + ▏▋▏▏▏ ▎▏ + ▏▏▏▏▎▏▎▏ + ▏▌▏▉▋█▏█ + ▏▏▉ ▋▊ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_3.txt b/codex-rs/tui/frames/vbars/frame_3.txt new file mode 100644 index 0000000000000000000000000000000000000000..6c202bc0c38788e9f19267c980fe2e6f5bdc80fc --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_3.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▉▉▌▌▌▌▊▎ + ▎▉▊▋▍▏▏▉▏█▌▌▏▌▎▍▏▉▉▎ + ▊▌▋▉▌▏▉██▎ ▎█▏ ▉▉▉▋▍▏▊ + ▋▉▋▍▏▌▊▉▎ ▎▌▉ ▏▏▍ + █▋▉▏▋▍▋▏▏▉▍▎ ▍▍▍▏▏▎ + ▋▏▍▍▋ ▋▊▏▍▌▍▍ ▏ ▍▍▏ + ▌▉ ▏ █▉▍▏▎█▏▊ ▋▉▋▏▊ + ▊▊▏▊▏ ▊▊▏█▋▍▏ ▏▎▏▌▏ + ▊██▏ ▌▏▉▉▏▏▉▊▏▌▉▉▎▎▎▉▉▎▏▍█▌▏ + ▊▍▋▍▊ ▋▋▉▋▌▌▏ ▏▉▏▎▎▊ ▌▋▊▌▍▎▏▍▏▎ + █▍▏ ▏ █▏▏▉▊█ █▉▉▉███▉▉▊▋▋▋▊▋ + █▉▏ ▍▉ ▎ ▋▍▉▍▋▋ + ▌▍▋▋▉▍▊ ▊▉▌▋█▊▏█ + ▌▍▉▊▎▉▉▍▌▌▌▌▊▋▌▉▌▎▎▉█ + ▎▉▉▉▎▋▏▎▎▎▎▏▌▋▍█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_30.txt b/codex-rs/tui/frames/vbars/frame_30.txt new file mode 100644 index 0000000000000000000000000000000000000000..a44dbb6ed04857c5021b141bf653764f86780503 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_30.txt @@ -0,0 +1,17 @@ + + ▎▌█▉▉▉▊ + ▎▋▌▏▏▍▉█▌▍ + ██▍▏▊▍▍▏▉▊▍ + ▏▏▍▏▋▏▎▍▏▍ ▍▊ + ▌▉▏▏▎▍▏▊▏▊ ▏ + ▏▉▏▍▉ ▉▏▏▏▏▋▏ + ▍▋▏▏▏▍▎ ▍▋▍▏ ▌▎ + █▏ █▉▌▏▊▏█▏▊ ▎ + █▏▎█▍▏▌▏▍▋▏▊ ▎ + █▏▏▍▏▎▎▏▏ ▉▊ ▎ + ▍▏▏▌▍▎▉▏▏▏▉▏▏ + ▏▉▋▋▊▋▉▍▏▏█ ▏ + ▍▏▍▏▏▋▉▏▏▊▏▋ + ▊▍▊▉▌▍▋▏▊▋█ + ▍▊▋▏▍▎▎▌█ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_31.txt b/codex-rs/tui/frames/vbars/frame_31.txt new file mode 100644 index 0000000000000000000000000000000000000000..70da8799e2975e6d24475a1bf56d70d7f5da40ad --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_31.txt @@ -0,0 +1,17 @@ + + ▎▋▋▉▉▉▉▌▊ + ▎▉▉▌▉▊▎█▉▍█▉▊ + ▊▏▍▋▋▊▌▎▎▋█▏▎▉▍ + ▊▏▋▉▎▎▉▌▎▊▍▍▏▍▏▏▍ + ▍▏▏▏▏▉▊▉▍▎▊█▍▏▋▎▎ + ▌▏▍▎▏▎▏██▍▌▉▊▋▋▏▊█▏ + ▋▏▏ ▏▍▉▏▍▋▌▋▌▉▏▉▏▋▏ + ▍▍▊ ▏▏█▏▍ ▏▍██▏▏▍▋▏ + ▏▍▊▎▏█▍▏▏▏▋▉▏▏▏▎ ▏ + █▏▏▏▋▋▏▏▎▎▎▍▎▏▍▏▏ ▉ + ▍▉▎▋▉▏▊ ▋▉▉▉▎▋▏█▏▎ + ▏▏▏▍▍▍▊ ▎▌▉ ▏ + ▌▏▉▌▎ ▊ ▋ ▏▉▎▋▎ + ▋▏▍▏▋▎▏▎▊▏█▊▏▎ + ██▌▏▎▉▉▋▋▌▉ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_32.txt b/codex-rs/tui/frames/vbars/frame_32.txt new file mode 100644 index 0000000000000000000000000000000000000000..ddfb4be3fe2d5cb82a1cfc28d2a117cc216f3453 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_32.txt @@ -0,0 +1,17 @@ + + ▊▉▉▉▉▌▌▏▊▎ + ▌▉▏▉▉▏▍▏▏▉▉▏▏▊ + ▋▍▉▌▍▊▉▍▋▊▎▉▏▉▍█▉▊ + █▋▋▏▎▊▎▌▊ ▊ ▊▎▍▏▉▊ ▊ + ▋▋▏▊▏▉▍█▉▏▌ █▎▊▏▍▋▍▉▍▊ + ▍▋▍▍▊▍▉▏▎▋▏▏▉▎ ▏▉▋ ▉ + ▉▏█▌▊▎█▍▏▏▊▊▏▊▌▎▋▎▋▋▏█▎ + ▏▏▏▊▎ █▎▏▏█▋█▏▌▊▎█▍▏██▎ + ▍▏▏▊▎ █▎▏█▎▎▏▉▉▉▏▏▏▌▏█▌▎ + ███▎▎▏▉▍█▍▎▎▎▎▎▏▏▉▏▌▏▊▎ + ▊▍▉▏▏▋ ▏▉██▉▉▉▍▌▋▋▋▋▋ + ▍▊▏▍▍▍ ▊▍▏▋█▏ + █▉▉▏▏▏▉▎ ▎▋▎▏▋ ▊ + ▍▊▍█▏▉▏▌▌▉▎▎▉█▎▋ + ▏▌▌▎▎▊▉▋▉▎██ + ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_33.txt b/codex-rs/tui/frames/vbars/frame_33.txt new file mode 100644 index 0000000000000000000000000000000000000000..7fa5ac29bcac6b99a23f5d498484702a6aade100 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_33.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▉▌▉▌▏▋▎ + ▎▉▉▏▏▍▉▌▏▋▏▊▏▉▏▉▋ + ▎▋▍▏▉▉▉▊▌█▎ █▉▋▏▏▉█▊ + ▊▍▋▏▋▎▏▊▎ ▉▏▏▍▎▉ + ▎▋▏▋▎▏▏▋▍▍▍ ▏▉▍▍▍ + ▋▋▊ ▍▎▍▍▎▍▊ ▋▍▋▎▍ + ▏▏▉ ▍▊▉▏▎▏▍ ▏▉▏▏ + ▉▏▊▊ ▊██▍▋▌▊ ▍█▏▉ + ▏▏▏ ▏ ▊▎ ▋▉▊▊▊▌▌▌▉▏▏▉▋▎▏▍▏ + ▍▋▏██▋▋▊▋▎▏▏▎▎▎▎▎▏▏▉▍▍▍▏▏▋ + ▍▌▍▊ ▏▋▏▋ ▋ ████▉▉▉ ▏▏▊▋▍▎ + ▍▉▍▉▊█▊ ▋▋▌▋▌▋ + ▉▍█▉▊▉▌▊ ▎▉▏▊▋▉▍█ + ▍▏█▉▍▉▏▊▌▉▎█▌▊▍▉▏▉ + █▌▌▉▊▎▎▎▍▉▉▌▊█ + ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_34.txt b/codex-rs/tui/frames/vbars/frame_34.txt new file mode 100644 index 0000000000000000000000000000000000000000..a8c447ff18a5ce3830cf500d3840fad2191fa40b --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_34.txt @@ -0,0 +1,17 @@ + + ▎▊▌▉▉▉▉▌▏▊▎ + ▎▉▉▉▋▍▌▏█▏▉▉▌▉▉▌▊▌▎ + ▎▋▋▊▏▉█▋▏▉▎ ▎▍▍▍▊█▉▉▊ + ▊▋▎▏▉▎▋▊▎ ▉▏▊▉▍▍ + ▋▋▏▍▊▋▏ ▏█ ▊ ▍▍▉▍▍ + ▊▋▍▏▎▋ ▍▍█▏▍▉▍ ▍▊▍▏▍ + ▏▉▍▏▎ █▍▎▉▏▎▍ ▏▌▏▎ + ▍▏▌█▋ ▏▉▎▏▋█▏ ▋ ▎▍ + ▊█▋▋█ ▋▍▋▋█▎▌▌▉▉▉▉▉▉▏▎▎█ ▏▍▎ + ▏▏▍▊▋▊▊▍█▋▋█▏▏▏▎▏▋▎▎▎▊▋▎▏▏▎▏▍ + ▋▎▍▊ ▏▍▏▉▍ █▉▉▎▎▎▎▍ ▊▋▊▍▏█ + ▋▊▍▌▌▍▎ ▋▌▋▏▋█ + ▍▉▎▏▉▍▌▊ ▎▉▌█▎▋▊▎ + ▊▍▋▏█▏▉▊▌▊▉▌▉▌▉▎▉▉▋█ + █▏▍▌▌▎▎▎▎▎▌▌▉▌▍ + ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_35.txt b/codex-rs/tui/frames/vbars/frame_35.txt new file mode 100644 index 0000000000000000000000000000000000000000..ba905231e1f1865d96eca403bb25dbd95b65a98b --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_35.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▌▉▉▉▌▉▊▎ + ▊▉▌▉▋▏▉▉▉█▏▉▏▋█▏▉▌▎ + ▎▋▋▏▌▉▉▏▍▉█▎ ▎█▉▌▉▋█▏▉▊ + ▊▋▋▉▋▊▌▊▎ ▉▉▎▍▏▎ + ▋▋▏▏▋▋▍ █▍▉▊ ▏▍▉▏▊ + ▌▋▌▋▉▎ █▏▎█▋▋▉▎ ▏▍▍▏ + ▍ ▋▋ ▍▍ █▉▎▋ ▏▍▍▍ + ▏▏▏▋ ▋▍▍▌▋█ ▋ █▋ + ▋ ▊▎ ▊▏▏▋▌█▋▏▌▌▎▌▎▌▉▏▏ ▏▏▌▉ + ▏▊▍▌█ ▋▋▌█▉▉▋▏▌ ▍▊▎▎▎▎▋█▏▋█ ▏█ + ▋▎▍▊█▋▍▎▏▋▍▎ ▎█▍▍▍▍▉▍▍▉▋▋▎▊▏ + █▋▍▍▋▏ ▎▋▉ ▊▍ + ▍▌▎▍▌▊▋▊ ▊▌▋▎▉▉█ + █▍▎█▏▉▏▊▋▏▏▉▏▌▋▉█▎▋▌█ + █▍▏▌▌▎▎▋▎▎▉▉▉█▍ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_36.txt b/codex-rs/tui/frames/vbars/frame_36.txt new file mode 100644 index 0000000000000000000000000000000000000000..246ed3d6924cebf72f4a99e9aaf6c60ef6acf71b --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_36.txt @@ -0,0 +1,17 @@ + + ▎▎▉▌▉▉▉▉▌▉▊▎ + ▎▌██▍▉▋▌▋▉▍▉▌▉▉█▉▉▉▎ + ▊▍█▍▊▉▉▊▉█▎▎ ▎█▉▏▉▉▏▊▉▏▊ + ▎▏█▉▉▎▎▎▎ █▉▏▍█▏ + ▊▋▎▌▍█▏▍▎▍▊▋ ▍▍▋▉ + ▋ ▊▋▎ ▉▎ █▏▋▊ █▍▍▉ + ▏ ▌ ▎ ▍ █▍▍ ▏▍▏▊ + ▏▏▍▏ ▎▍ ▎▋▋ ▍▏ ▏ + ▏██▏ ▋▋ ▊▉██▊▉▉▎▉▉▉▉▉▎ ▏▍▏▏ + ▎▉▏▍ ▊▏▎▎▋▏▋ ▎▏▎▎▎▎▎▎▎▏▏ ▍▋█▎ + █ ▉▏▍ ▉▎▉▋▍ █▉▉▍▍▍▍▍█ ▏▊█▋ + █▊█▍▌▋ ▊▋▏█▋ + ▍▋▏▏▉▏▊ ▊▉▉█▋▊▎ + ▉▊▎█▉▉▌▍▌▏▎▉▏▌█▊█▊▌▉█ + ▎▉▉▋▏▎▊▊▎▊▊▉▉▉▉█ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_4.txt b/codex-rs/tui/frames/vbars/frame_4.txt new file mode 100644 index 0000000000000000000000000000000000000000..5dcae750bc0fafbf47cc3d9ee21cdb1ed93d35fc --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_4.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▉▌▌▉▌▉▊▎ + ▎▊█▉▉▏▋ ▏▍▌▋▉▌▏█▋▉▊▎ + ▊▉▌▏▏▏▉▉█▎ ▎▍▏▍▍▉▏▋▍▍▎ + ▋▋▏▊▏█▋▉▊ █▏▉▉▍▉▍ + ▋▏▋▎▏▌▏▍▌▍▏▊ ▍█▍▏▉▍ + ▋▏▏▍▏ █▎▏▏▋▉▍ █ ▍▌▏▊ + █▍▏▌▎ █▉▉▍▉█▏▊ ▊ █▌▏ + ▏▋▍ ▊▏▋▉▍▏▏ ▏▏▏ + ▎▏ ▏▎ ▌▏▋█▍▋▋▉▏▉▉▉▎▎▎▎▊▊ ▏▏▉ + ▏▍▋ ▏ ▊▋▋▋▊▎▏▎▌▍▏▊▋▎▊▋▋▍▌▏▋▋█▏ + ▎▏▌█▍▊▍▍▊▍▋ █▉▉▉▉▉▉▉█▍▊▏▏▏▎ + ▊▏▍█▏▎▎ ▊▍▌▋▉▉█ + ▍▍▊▋▍ ▊▎ ▎▋▍▊▉▌▏▋ + ▏▏▏▌▎▉▉▍▌▌▌▌▊▉▌▉▉▍▏▉▎ + ▉▍▍▏▋▏▎▎▎▎▎▌▊▉▎ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_5.txt b/codex-rs/tui/frames/vbars/frame_5.txt new file mode 100644 index 0000000000000000000000000000000000000000..cab16091cb9b8bf0e3337be3d85232e397a0dd78 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_5.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▌▉▌▌▌▉▊▎ + ▎▉█▉▋▉▋▏▏▉▏▌▉▌▎▏█▉▊ + ▉▎▋▍▉▏▉█▎ █▍▌▏▉▍ ▊▉▊ + ▊▏▋▏▋▏▋▌▊ ▊▍▏▏▏▏▎ + ▊▍▋▉▋▍▎▉▏▎▍▊ ▎ ▍▏▍▏▎ + ▏▏▏▏▋ ▍▎▏▍▌▋▏▎ ▉▋▏▏ + ▏▋▏ ▉ ▎▍▎▏▍█▏▊ ██▏▉▏▊ + ▏▋▏▎█ ▊▎▌▏▋▎▏ ▉▏▎▏ + ▏▊▏▍▉ ▋▎▏█▊▏▉▌▎▏▉▏▎▎▎▉▊▎█▏▋▏ + ▎▍▏▍▊▊▋▉▉█ ▏█ ▋▋▍▌▍▊▌▋▊▋▏▋▉▏▎ + ▍▊▏▏▊▏▊▍▍▋▉▎ █▉▉█████▊▏▌▏▍▋ + ▍▍▉▋▍▍ ▎ ▋▎▋▉▊▍ + ▍▋▉▍▏▌▉▎ ▎▌▉▊▉█▌▉ + ▍▏▉▏▊▉▉▍▌▏▌▎▊▉▌▉▍▏▌▉ + ▍▊▉▎▉█▎▎▎▊▎▊▉▉▎ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_6.txt b/codex-rs/tui/frames/vbars/frame_6.txt new file mode 100644 index 0000000000000000000000000000000000000000..e41e013ab0faffc994d74fa060664ab741f66c93 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_6.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▌▉▌▌▉▉▎▎ + ▊▍▌▋▉▏▌▉▉▏▉▉▌▎▊▉▉▎ + ▊▊▊▏▏▏▉█ ▎█▏▋▌▉▉▉▋▍▎ + ▋▊▏▉▏▉▎▌▊ ▍▉▏▏▏▏▊ + █▌▏▏▏▉▍▍▉▉▍ ▋▍▍▏▍▊ + ▌▊▋▏▋▎▍▉▌▏▍▎▏▊ ▋▌▍▋▏ + ▎ ▉▏▏ ▍▍▏▋▍▍ ▍▉▏▎▏ + ▏ ▏▏▏ ▎▏▌▏▋▏▏ ▏▉▍▏▏ + ▎▋▏▊▏ ▋█▏▏▉▌▋▉▎▏▉▏▎▎▉▉▎▋▍▋▏ + ▏▏▍▊▋▎▍▎▌▉ ▏▋▌▉▊▏▏▊▌▌▌▍▏▊▏▍ + ▋ ▏ ▋▏▏▏▌▋█ █▉████▉█▎▍▍▋▏ + ▊ ▌ ▋▊▎ ▊▋▎▋▋▏▎ + ▋▎▍▎▉▉▊ ▎▉▍▎▋▉▊▉ + ▎█▏▉▊█▉ ▌▏▎▎▊▉▉▉ ▋▌█ + ▍▊▌▉▉▊▎▎▎▎▎▊▉▉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_7.txt b/codex-rs/tui/frames/vbars/frame_7.txt new file mode 100644 index 0000000000000000000000000000000000000000..7a88d5ef148f632674111bad9fafc9d03d3f5193 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_7.txt @@ -0,0 +1,17 @@ + + ▎▋▏▌▉▉▌▌▉▊▎ + ▊▉▎▉▍▏▉▉▉▌▌▍▌ ▎▉▊ + ▉ ▊▏▏▏▉▎ ▎▍▏▎ ▉▋▎▉▊ + ▍▍▏▏▏▏▌▉ ▍▏▍▉▏▋▊ + ▎█▏▉▏▏▍▏▋▍▊ ▋ ▉▏▏▍ + ▏ ▌▏▏█ ▎█▍▌▏▍ ▊▊▏▏▏ + ▎▍█▏ ▍▎▏▍▏▍▏ ▉▉▍▏▏ + ▎█▋▏▏ ▌▉▋▏▉▏▎ █ ▏▏▏ + ▋█▏▉ ▊█▎▋▏▍▋▏▏▌▌▎▏▏▌▌▉▍▏▏ + ▏▊█▏▏▌▋▏▏▋█▏▋▍▏▏▍▍▊▌▊▌▍▏▍▏ + ▍ ▌▍▋▉▏▉▎▋ ▉▉▉▉▉▉██▎▋▏▏ + █▎ ▋▎▋▎▎ ▊█▊▋▌▋█ + █▌▊▉▍▍▍▎ ▊▍▏▊▉▋▉█ + ▍▎█▍▏▉▍▌▏▎▎▎▉▉▏▉▉▋ + █▏▉█▏▋▏▎▎▊▎▌▉▉ + \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_8.txt b/codex-rs/tui/frames/vbars/frame_8.txt new file mode 100644 index 0000000000000000000000000000000000000000..bbf2016fabae0eac3862ce88d14aaf2f2d6d4963 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_8.txt @@ -0,0 +1,17 @@ + + ▎▎▉▎▌▉▉▌▉▊▎ + ▊█▉▋▏▏▏▉▌▌ ▏ █▉ + ▎▏▌▊▏▋▋▉ ▊█▏▋▏▉▍▉▍▊ + ▎▉▍▉▋▏▊▉▏ █ ▌▍▏▏▍ + █ ▋▏▋▊▍▏▋▍ ▋█▍▏▏▊ + ▏█▊▏▍▍▍▎▍▏▎▉ ▍▉▏▏▏ + ▏▉▏▏▏ █▎▌▍▏▎▏ ▏▍▏▋▏▊ + ▍▊▏▏▋ ▏▉▍▋▏▊▉▋ ▋▎▋▏▏ + ▍ ▍▏▏▎▍ ▋▉▌▏▏▋▉▊▉▊▊▍▏▏▏ + ▍▉▋▌▎▏█▊▏▊▏▏▌▏▌▎▎▏▏▌█ + ▋▋▍▍▏▍▏▏▏▋█▍▉▍▉█▍▉█▋▍▏ + █▊█▏▍▏▎▎ ▋▊▋▋▋█ + █▍▎▉▍▍▍▎ ▎▋▍▎▏▉▋▉ + ▋▋█▏▊▉ ▌▌▉▉▌▋▌▊▏▎ + ▏▏▎▉▉▍▏▎▊▉▋▌▎ + ▎▎ \ No newline at end of file diff --git a/codex-rs/tui/frames/vbars/frame_9.txt b/codex-rs/tui/frames/vbars/frame_9.txt new file mode 100644 index 0000000000000000000000000000000000000000..4e36e6e126f25a884b6c547ee1b328b251642931 --- /dev/null +++ b/codex-rs/tui/frames/vbars/frame_9.txt @@ -0,0 +1,17 @@ + + ▋▌▍▉▋▉▉▌▊ + ▋▉▎▋▏▏▉█▌ ▎▊▍▎ + ▋ ▊▏▏▋▉▍▍▌▋ ▍▎▏▊ + █ ▏▏▋▌▌▎ ▎▍▊▏▍▍▏▉▊ + ▋▉█▌▍▉█▏▉▊ ▉▋█▉▋▉▏▏ + ▏ ▏▏▏█▍▏▍▌▎▎ ▏█▏▉▏▏▏ + ▎ ▏▉▍▏▉▉▏▍▍▊█▋▊▋ ▎▏▏ + ▏ ▏▋▎▊▍▏▏▏▎▋▌▍▎▏ ▏▏ + █▊▏█ ▋▏█▏▏▏▏▉▏▏▊▏▏▎ + ▏▎▏▏▏▎▊▏▍▏▎▏▏▏▏▎▎▏▏▏ + █ ▎▍▋▍▍▏▋█▉▉▉▉▏▏█▋▍▏ + ▍▏▏▏▍▊▎ ▊▋ ▍▋▏▎ + ▍▉▍▏▍▍ ▋▌▎▌▏▋▉ + ▍▊█▍▎▏▉▋▉▌▌▌▉▏▎ + █▊▎ ▉▍▍▏▌▉▋█ + \ No newline at end of file diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap new file mode 100644 index 0000000000000000000000000000000000000000..ccf9a2877e484e7945255d75d2049d6eab9f6783 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n48).replace(&test_path_display(\"/tmp/project\"), \"/tmp/project\")" +--- + Agent command center + 0 need input 0 working 1 ready + ──────────────────────────────────────────── + /tmp/project 1 + › ○ Task Ready + + + + + + + + + + + + + + + + ↑↓ navigate o resume enter open n new + w new worktree f search g group: project + r rename x stop f5 f8 hide f5 f6 archive + f5 f7 delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap new file mode 100644 index 0000000000000000000000000000000000000000..9808a756b88440e187f604b14a0cd64ddad018af --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 72)" +--- + Archive “Current task”? + This stops any running work in this task and its child agents, then + archives them. Their history can be restored from the resume picker. + +› 1. Cancel + 2. Archive task and child agents + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap new file mode 100644 index 0000000000000000000000000000000000000000..7556b48701c93458c9d81c4d9c9e0e2cd2633c40 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Could not archive task + failed to archive session: thread/archive failed: no rollout found for + thread id 00000000-0000-0000-0000-000000000001 (code -32600) + Work may have stopped. Resume the task to continue, or retry the action. + +› 1. Back to agents + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap new file mode 100644 index 0000000000000000000000000000000000000000..226b8a059299bf80b627413403ef5beea3620ec8 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 72)" +--- + Permanently delete “Current task”? + This stops any running work in this task and its child agents, then + permanently deletes their history. This cannot be undone. + +› 1. Cancel + 2. Permanently delete task and child agents + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap new file mode 100644 index 0000000000000000000000000000000000000000..d6f279575796a563b7da765c6b7400fe536c839d --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Could not delete task + failed to delete session: thread/delete failed: no rollout found for thread + id 00000000-0000-0000-0000-000000000001 (code -32600) + Work may have stopped. Resume the task to continue, or retry the action. + +› 1. Back to agents + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap new file mode 100644 index 0000000000000000000000000000000000000000..a5fdfdd9c2726b4da327922415a186cf39396a3d --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown.snap @@ -0,0 +1,44 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "cached.replace(&format!(\"{project} 1\"),\n&format!(\"/tmp/project 1{padding}\")).replace(&project,\n&format!(\"/tmp/project{padding}\"))" +--- +" Agent command center " +" 0 need input 0 working 1 ready " +" ──────────────────────────────────────────────────────────────────────────────────────────── " +" /tmp/project 1 │ Task details " +" › ○ Review parser Ready │ " +" │ Review parser " +" │ ○ Ready " +" │ " +" │ Last message " +" │ ## Findings " +" │ " +" │ - Fixed parsing and tokens with a long " +" │ explanation that wraps. " +" │ " +" │ - Kept compatibility. " +" │ " +" │ let token = 1; " +" │ " +" │ Project " +" │ /tmp/project " +" │ Model: Unknown " +" │ " +" │ Prompt " +" │ Review parser and token handling. " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" │ " +" ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename " +" x stop h hide a archive del delete ctrl+c quit " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap new file mode 100644 index 0000000000000000000000000000000000000000..d4149a0e94511caff0beb45ee51bded1f3efa696 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_long_lines.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 1\"),\n&normalized_group).replace(&project, \"/tmp/project\")" +--- + Agent command center + 0 need input 0 working 1 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 1 │ Task details + › ○ Review parser Ready │ + │ Review parser + │ ○ Ready + │ + │ Last message + │ let explanation = "A long code line + │ should wrap inside the task details + │ panel."; + │ + │ Project + │ /tmp/project + │ Model: Unknown + │ + │ Prompt + │ long prompt long prompt long prompt + │ … + │ + │ + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap new file mode 100644 index 0000000000000000000000000000000000000000..6234eb0161726e837cf8a13e4ef70f0387c36781 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_markdown_table.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 1\"),\n&normalized_group).replace(&project, \"/tmp/project\")" +--- + Agent command center + 0 need input 0 working 1 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 1 │ Task details + › ○ Review parser Ready │ + │ Review parser + │ ○ Ready + │ + │ Last message + │ Check Result + │ ━━━━━━━━ ━━━━━━━━ + │ Parser Fixed + │ + │ Project + │ /tmp/project + │ Model: Unknown + │ + │ Prompt + │ long prompt long prompt long prompt + │ … + │ + │ + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap new file mode 100644 index 0000000000000000000000000000000000000000..ed924d7563ce04615f6c558d339950397c1caf7f --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__agents_overview_model_grouping.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n100).replace(&test_path_display(\"/tmp/project\"),\n\"/tmp/project\").replace(\"del\", \"del\")" +--- + Agent command center + 0 need input 0 working 4 ready + ──────────────────────────────────────────────────────────────────────────────────────────────── + Unknown 1 │ Task details + › ○ Legacy task Ready │ + │ Legacy task + model-a 2 │ ○ Ready + ○ Recent task Ready │ + ○ Older task Ready │ Project + │ /tmp/project + model-b 1 │ Model: Unknown + ○ Other model Ready │ + │ Prompt + │ Legacy task + │ + │ + │ + │ + │ + │ + │ + │ + ↑↓ navigate o resume enter open n new w new worktree f search g group: model r rename x stop + h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap new file mode 100644 index 0000000000000000000000000000000000000000..4fcf2455ebc281c936f42067ea1ce7eb39b68e34 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_narrow_overview.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 12)" +--- + Service + v0.152.1 + < Codex + CLI + v0.153.0 + 0 need i + ──────── + + + + + + + + + + + + n new + w new + worktree + f search + ctrl+c + quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_notice_is_visible_in_agents_overview.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_notice_is_visible_in_agents_overview.snap new file mode 100644 index 0000000000000000000000000000000000000000..6f4e1bcce3ce3a4a7ae2fa4bf6535a3f684e7a5d --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__older_server_notice_is_visible_in_agents_overview.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/agents_overview_tests.rs +assertion_line: 10 +expression: "rendered.lines().take(2).collect::>().join(\"\\n\")" +--- + Service v0.152.1 < Codex CLI v0.153.0 + 0 need input 0 working 0 ready diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap new file mode 100644 index 0000000000000000000000000000000000000000..21a2fa6d88ce4caf661f9c79e753c5183a6c9391 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_empty_prompt_right_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: rendered.lines().last().unwrap() +--- + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap new file mode 100644 index 0000000000000000000000000000000000000000..23f26d98052c8e5379cb7506596052390a1ef996 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_right_open_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "rendered.lines().find(|line| line.contains(\"open\")).unwrap()" +--- + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_thread_colors_match_footer_and_respect_color_suppression.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_thread_colors_match_footer_and_respect_color_suppression.snap new file mode 100644 index 0000000000000000000000000000000000000000..80e91144e56877d30c7e7d68c2a96573bebc2250 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_thread_colors_match_footer_and_respect_color_suppression.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "snapshot.join(\"\\n\")" +--- + › ○ Named task Ready │ | title style: Style::new().fg(Color::Rgb(242, 205, 205)).bg(Color::Reset).underline_color(Color::Reset) + │ Named task | title style: Style::new().fg(Color::Rgb(242, 205, 205)).bg(Color::Reset).underline_color(Color::Reset).bold() + › ○ Named task Ready │ | title style: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset) + │ Named task | title style: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold() diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_worktree_creation_busy_state.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_worktree_creation_busy_state.snap new file mode 100644 index 0000000000000000000000000000000000000000..31b22fda4f4c998813bfb8b68f9e78aa97f01674 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__overview_worktree_creation_busy_state.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Agent command center + Creating worktree… + ──────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + + + + + Creating worktree… ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap new file mode 100644 index 0000000000000000000000000000000000000000..71287f8cc636363ca0149970103e28dbba9c03f8 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__usage__agents_overview_usage.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_usage_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&group).replace(&project, \"/tmp/project\").replace(\"del\", \"del\")" +--- + Agent command center + 1 need input 0 working 1 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● Review parser Needs input │ + ○ Other task Ready │ Review parser + │ ● Needs input + │ + │ Needs attention + │ Which dependency version should I use? + │ Open task to review. + │ + │ Project + │ /tmp/project + │ Model: Unknown + │ Tokens: 13K in · 4K out + │ Est. usage: 5 credits · ~$0.20 + │ + │ Prompt + │ Review parser + │ + │ + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename + x stop h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap new file mode 100644 index 0000000000000000000000000000000000000000..d6bd0e16e71391c3ac0df861b8513064abb2a5eb --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/agents_overview_action_progress_tests.rs +assertion_line: 62 +expression: terminal.backend() +--- +"Archiving task… " +"Please wait. Task switching is " +"unavailable until this finishes. " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap new file mode 100644 index 0000000000000000000000000000000000000000..2a2f6f66c33e59e5a318f21f570aca3335ea3a86 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/agents_overview_action_progress_tests.rs +assertion_line: 62 +expression: terminal.backend() +--- +"Deleting task… " +"Please wait. Task switching is " +"unavailable until this finishes. " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap new file mode 100644 index 0000000000000000000000000000000000000000..7a8fb7160c4fa2e8e8c07eb2ab1a5ce5107a2e5f --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_cli_confirmation.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\nwidth).replace(r\"C:\\cli-build\\bin\\codex\", \"/x/cli-build/bin/codex\")" +--- + Update daemon and exit Codex? + Use this CLI package v0.0.0 from /x/cli-build/bin/codex. The complete local package will be + copied and pinned against automatic updates. + The daemon will restart if needed. Active or queued work may be interrupted. + Codex will exit and run the update in this terminal, then return to the shell. Relaunch Codex + afterward. + +› 1. Cancel + 2. Update and exit + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap new file mode 100644 index 0000000000000000000000000000000000000000..b28f674e84465a80041fc832360165b4264eb945 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_disconnected.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Not connected to the local background server. + +› 1. Install latest public stable + 2. Use this CLI build + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap new file mode 100644 index 0000000000000000000000000000000000000000..24f632d8a8f034aae1f2e045256d14acf9ff7733 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_menu.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Running daemon: v0.153.0 + +› 1. Install latest public stable + 2. Use this CLI build + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap new file mode 100644 index 0000000000000000000000000000000000000000..61e52edc63371dc7bdb03cdfbaecd83c2b133ccd --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_no_cli_handoff.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Not connected to the local background server. + Run the Codex CLI to manage the daemon from this menu. + +› Install latest public stable + Use this CLI build + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap new file mode 100644 index 0000000000000000000000000000000000000000..f590a18d748532fa46d8bc67218b6e7c84137cb7 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_remote_guidance.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Not connected to the local background server. + Manage this server on its host. Local daemon updates are unavailable for + remote connections. + +› Install latest public stable + Use this CLI build + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap new file mode 100644 index 0000000000000000000000000000000000000000..f8028d94b44c329657ef1bcd52b5b5a6f47052a7 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_stable_confirmation.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\nwidth).replace(r\"C:\\cli-build\\bin\\codex\", \"/x/cli-build/bin/codex\")" +--- + Update daemon and exit Codex? + Install the latest public stable release (version resolved when the command + runs). Production update eligibility will be restored; your automatic-update + setting is preserved. + The daemon will restart if needed. Active or queued work may be interrupted. + Codex will exit and run the update in this terminal, then return to the + shell. Relaunch Codex afterward. + +› 1. Cancel + 2. Update and exit + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap new file mode 100644 index 0000000000000000000000000000000000000000..17f12ed62e1bd733b686bd0e215a2582be57a134 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__daemon_menu__tests__daemon_unpackaged_cli.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/daemon_menu_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Daemon + Not connected to the local background server. + +› 1. Install latest public stable + Use this CLI build (disabled) disabled: This CLI has no local package to + copy. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__memories_enable_notice.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__memories_enable_notice.snap new file mode 100644 index 0000000000000000000000000000000000000000..be574339701cd5d2576ff7851bc890152fbc01ee --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__memories_enable_notice.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/experimental_features_tests.rs +expression: "app.transcript_cells.last().unwrap().display_lines(120).iter().map(ToString::to_string).collect::>().join(\"\\n\")" +--- +⚠ Memories setting saved on the server for new threads. This thread is unchanged. Project or task settings may override + it. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__subagents_enable_notice.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__subagents_enable_notice.snap new file mode 100644 index 0000000000000000000000000000000000000000..6666c6297966a4c5f9de02e90605b0dbdb041ea3 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__subagents_enable_notice.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/experimental_features_tests.rs +expression: "app.transcript_cells.last().unwrap().display_lines(120).iter().map(ToString::to_string).collect::>().join(\"\\n\")" +--- +⚠ Subagents setting saved on the server for new threads. This thread is unchanged. Project or task settings may override + it. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__subagents_enable_overridden.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__subagents_enable_overridden.snap new file mode 100644 index 0000000000000000000000000000000000000000..0de6e434f17b98aea2199fb71d2a3924fe4d54cc --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__experimental_features__tests__subagents_enable_overridden.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/experimental_features_tests.rs +expression: "app.transcript_cells.last().unwrap().display_lines(120).iter().map(ToString::to_string).collect::>().join(\"\\n\").replace(managed.display().to_string().as_str(),\n\"\")" +--- +■ Subagents setting was saved but is overridden: Overridden by legacy managed_config.toml: diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap new file mode 100644 index 0000000000000000000000000000000000000000..16e3549a2023e20c518b0e86b5384753d47bfdb3 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_open_error_history.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/history_ui_tests.rs +expression: render_cell(&cell) +--- +■ Failed to open this session in the Desktop app: launch failed. Install or launch the Desktop app and try again. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap new file mode 100644 index 0000000000000000000000000000000000000000..8e34f8e8a64508e0ac897e8a6eab8bea1b429015 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__history_ui__tests__desktop_thread_opened_history.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/history_ui_tests.rs +expression: render_cell(&cell) +--- +• Opened this session in the Desktop app. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__app_server_thread_replacement_clears_previous_transcript_before_replay-2.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__app_server_thread_replacement_clears_previous_transcript_before_replay-2.snap new file mode 100644 index 0000000000000000000000000000000000000000..4b9aa6a689ae55e7a793848d296de321ecfd8c57 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__app_server_thread_replacement_clears_previous_transcript_before_replay-2.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/tests.rs +assertion_line: 6350 +expression: rendered +--- +╭────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: gpt-test /model to change │ +│ directory: │ +╰────────────────────────────────────────╯ + + +› Next thread prompt diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__directive_only_completion_removes_streamed_directive.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__directive_only_completion_removes_streamed_directive.snap new file mode 100644 index 0000000000000000000000000000000000000000..84b516998a4239b859f09c7110b0d2ab8e981f1c --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__directive_only_completion_removes_streamed_directive.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests.rs +expression: "rendered.lines.iter().map(rendered_line_text).collect::>().join(\"\\n\")" +--- +before directive diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay.snap new file mode 100644 index 0000000000000000000000000000000000000000..5555aed2a892bffd7d2d00b017051588de3b1c9a --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests.rs +expression: "rendered.lines.iter().map(rendered_line_text).collect::>().join(\"\\n\")" +--- +latest user question + +• Final answer: + + Pattern Outcome + ━━━━━━━━━━━━ ━━━━━━━━━━━ + Table tail Preserved diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay.snap new file mode 100644 index 0000000000000000000000000000000000000000..5555aed2a892bffd7d2d00b017051588de3b1c9a --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests.rs +expression: "rendered.lines.iter().map(rendered_line_text).collect::>().join(\"\\n\")" +--- +latest user question + +• Final answer: + + Pattern Outcome + ━━━━━━━━━━━━ ━━━━━━━━━━━ + Table tail Preserved diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_goal_actions__tests__thread_goal_ephemeral_error_message_renders_snapshot.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_goal_actions__tests__thread_goal_ephemeral_error_message_renders_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..d2faad28d141c7c6eec6e06dac6d1415f8067f50 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_goal_actions__tests__thread_goal_ephemeral_error_message_renders_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/app/thread_goal_actions.rs +expression: terminal.backend() +--- + + +■ Goals need a saved session. This session is temporary. +Run `codex` to start a saved session, or `codex resume` / `/resume` to +reopen one. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__manual_rename_cancels_thread_title.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__manual_rename_cancels_thread_title.snap new file mode 100644 index 0000000000000000000000000000000000000000..a37e03f7fd37fae36cae4131651cf2e4df9f36dd --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__thread_title__tests__manual_rename_cancels_thread_title.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/thread_title_tests.rs +assertion_line: 363 +expression: normalize_snapshot_paths(popup) +--- +› Ask Codex to do anything + + gpt-5.2 default · /tmp/project · Keep this title diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__markdown_transcript.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__markdown_transcript.snap new file mode 100644 index 0000000000000000000000000000000000000000..d06cd9f1733702d4f42e557f978dbf23d2462493 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__transcript_export__tests__markdown_transcript.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/app/transcript_export_tests.rs +expression: "render_markdown_transcript(&cells).expect(\"exported transcript\")" +--- +# Codex conversation + +## User + +Explain **the change** + +## Assistant + +```rust +let answer = 42; +``` + +## Plan + +completed plan + +## Activity + + $ cargo test + +## User + +[Image #1] + +## User + +[Image #1] describe this diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__user_verification__tests__older_binary_verification_warning.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__user_verification__tests__older_binary_verification_warning.snap new file mode 100644 index 0000000000000000000000000000000000000000..01d537a93ad5b78ca0556357d020b4145e7850cf --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__user_verification__tests__older_binary_verification_warning.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/user_verification_tests.rs +assertion_line: 275 +expression: rendered +--- +⚠ The local Codex binary could not complete user verification. diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__user_verification__tests__remote_workspace_verification_warning.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__user_verification__tests__remote_workspace_verification_warning.snap new file mode 100644 index 0000000000000000000000000000000000000000..e961e9b929faaa769fdf08bed394c5a7a471e784 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__user_verification__tests__remote_workspace_verification_warning.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/user_verification_tests.rs +assertion_line: 273 +expression: rendered +--- +⚠ User verification is unavailable for remote workspaces. diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..c9b4fbe5b3ce805d8b46d1dcd5b36dc5f36eb83e --- /dev/null +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -0,0 +1,738 @@ +//! Active recovery keeps local input and policy while reusing ordinary thread resume. + +use super::*; +use crate::app::reconnect::ReconnectPresentation; +use crate::app::reconnect::reconnect; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::net::TcpListener; + +use super::disconnect::serve_reconnect_requests; + +#[tokio::test] +async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> Result<()> { + for (recovered_queue, edit_offline, resume_error_code, deferred_notice, notice_enabled) in [ + (true, false, -32603, false, false), + (true, false, -32603, false, true), + (false, false, -32603, false, false), + (true, true, -32603, false, false), + (true, false, -32600, false, false), + (false, false, -32600, false, false), + (true, true, -32600, false, false), + (true, false, -32603, true, true), + ] { + let pending_profile = !recovered_queue && resume_error_code == -32600; + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + app.local_settings.tui.show_server_version_notice = notice_enabled; + let id = ThreadId::new(); + let cwd = app.config.cwd.clone(); + app.config.model = Some("gpt-test".into()); + // Avoid platform-specific path widths in the mode-preservation snapshot. + app.local_settings.tui.status_line = Some(vec!["model-with-reasoning".into()]); + app.config + .permissions + .set_permission_profile(PermissionProfile::read_only())?; + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + AskForApproval::OnRequest, + )); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + app.active_thread_id = Some(id); + app.primary_thread_id = Some(id); + let mut thread_session = test_thread_session(id, cwd.to_path_buf()); + thread_session.approval_policy = AskForApproval::OnRequest; + thread_session.permission_profile = PermissionProfile::read_only(); + app.ensure_thread_channel(id) + .store + .lock() + .await + .set_session(thread_session.clone(), Vec::new()); + app.chat_widget.handle_thread_session(thread_session); + app.chat_widget.set_collaboration_mask( + crate::collaboration_modes::plan_mask(app.model_catalog.as_ref()).unwrap(), + ); + let cached_mode = app.chat_widget.effective_collaboration_mode().with_updates( + Some("gpt-test".into()), + Some(None), + /*developer_instructions*/ None, + ); + // A newer server can report a mode changed by another client while disconnected. + let server_mode = (!recovered_queue).then(|| CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + developer_instructions: Some("Updated by another client".into()), + ..cached_mode.settings.clone() + }, + }); + let expected_mode = server_mode.clone().unwrap_or(cached_mode); + let expected_submitted_mode = expected_mode.clone(); + assert!(!app.model_catalog.collaboration_modes.is_empty()); + if edit_offline { + app.chat_widget + .restore_user_message_to_composer("unacknowledged prompt".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + next_user_turn_op(&mut ops); + } + if recovered_queue { + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + app.chat_widget + .restore_user_message_to_composer("old queued input".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + } + app.chat_widget + .restore_user_message_to_composer("kept draft".into()); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + app.app_server_target = AppServerTarget::Remote { endpoint }; + if notice_enabled { + let (notice, key) = crate::status::remote_connection::pending_server_version_notice( + &app.local_settings.tui, + &app.app_server_target, + /*server_home*/ None, + "2.1.0", + Some("2.0.0"), + /*last_shown*/ None, + ) + .expect("older server should have a pending notice"); + app.reconnect.seen_version_notice = Some(key); + if deferred_notice { + app.pending_server_version_notice = Some(notice); + app.update_server_version_overview_notice("2.1.0", Some("2.0.0")); + } + } + let thread = json!({ + "id": id, "sessionId": id, "preview": "only once", "ephemeral": false, + "modelProvider": "test-provider", "createdAt": 1, "updatedAt": 2, + "status": {"type": "idle"}, "cwd": cwd, "cliVersion": "0.0.0", "source": "cli", + "turns": [test_turn("accepted", TurnStatus::Completed, vec![ThreadItem::UserMessage { + id: "input-1".into(), client_id: None, content: vec![UserInput::Text { text: "only once".into(), text_elements: Vec::new() }], + }])] + }); + let server = tokio::spawn(async move { + let mut methods = Vec::new(); + for attempt in 0..2 { + let (stream, _) = listener.accept().await?; + methods.extend(serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| std::future::ready(match request.method.as_str() { + "thread/resume" if attempt == 0 => Some(json!({"error": {"code": resume_error_code, "message": + if resume_error_code == -32600 { + format!("thread {id} is closing; retry thread/resume after the thread is closed") + } else { + "temporarily unavailable".into() + } + }})), + "thread/resume" => { + let params = request.params.as_ref().unwrap(); + assert_eq!(params["threadId"], id.to_string()); + assert!(params["model"].is_null()); + let mut result = json!({"thread": thread, "model": "gpt-test", "modelProvider": "test-provider", "cwd": cwd, + "approvalPolicy": "never", "approvalsReviewer": "user", "sandbox": {"type": "dangerFullAccess"}, "reasoningEffort": null}); + if let Some(mode) = &server_mode { + result["collaborationMode"] = json!(mode); + } + Some(json!({"result": result})) + } + "thread/read" => Some(json!({"result": {"thread": thread}})), + "thread/list" | "thread/loaded/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), + "thread/goal/get" => Some(json!({"result": {"goal": null}})), + "turn/start" => { + assert!(!recovered_queue); + let params = request.params.as_ref().unwrap(); + assert_eq!(params["input"][0]["text"], "fresh follow-up"); + assert_eq!(params["approvalPolicy"], if pending_profile { "never" } else { "on-request" }); + assert_eq!(params["sandboxPolicy"]["type"], if pending_profile { json!(null) } else { json!("readOnly") }); + assert_eq!(params["collaborationMode"], json!(expected_submitted_mode)); + Some(json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}})) + } + method => panic!("unexpected reconnect request: {method}"), + })).await?); + } + Ok::<_, color_eyre::Report>(methods) + }); + let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?; + std::fs::write( + app.config + .codex_home + .join("tui-thread-reference-capabilities"), + "not a directory", + )?; + session.remember_task_tool_thread(id); + let transport = crate::dynamic_tools_mcp::ThreadToolTransport::Mcp(Arc::new( + crate::dynamic_tools_mcp::DynamicToolMcpServer::start( + session.request_handle(), + codex_app_server_protocol::ThreadStartParams::default(), + app.app_event_tx.clone(), + app.dynamic_tool_status_updates.clone(), + /*managed_requirement*/ None, + ) + .await?, + )); + let mut overrides = None; + transport.configure_mcp(&mut overrides); + let mcp = overrides.unwrap().remove("mcp_servers.codex_tui").unwrap(); + let client = codex_http_client::HttpClientBuilder::new().build_direct()?; + let call_mcp = || { + client + .post(mcp["url"].as_str().unwrap()) + .header( + "Authorization", + mcp["http_headers"]["Authorization"].as_str().unwrap(), + ) + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("MCP-Method", "tools/call") + .header("MCP-Name", "list_threads") + .json( + &json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { + "name": "list_threads", "arguments": {}, "_meta": {"threadId": id, + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}} + }}), + ) + }; + + app.chat_widget.remote_connection = + crate::status::remote_connection::remote_connection_status_value( + &app.app_server_target, + Some("1.0.0"), + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + if pending_profile { + app.pending_server_profiles.insert( + id, + PermissionProfileSelection { + profile_id: "server-only".into(), + approval_policy: None, + approvals_reviewer: None, + display_label: "server-only".into(), + }, + ); + } + app.begin_reconnect(); + if deferred_notice { + assert_eq!( + app.agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .server_version_notice, + None + ); + } + if edit_offline { + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)), + ) + .await?; + assert!(!app.chat_widget.has_queued_follow_up_messages()); + assert!( + !app.chat_widget + .capture_thread_input_state() + .unwrap() + .recovered_queue + ); + } + let stale_picker_refresh = app.agent_navigation.begin_picker_refresh(id).unwrap(); + app.last_subagent_backfill_attempt = Some(id); + app.rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation, + ) + .unwrap(); + let before_disconnect = Instant::now(); + app.recap.note_focus_lost(before_disconnect); + for _ in 0..3 { + app.recap + .note_turn_finished(&TurnStatus::Completed, before_disconnect); + } + app.schedule_recap_check(id, Instant::now()); + app.pending_managed_worktree_creation = true; + app.agents_overview + .view_state + .lock() + .unwrap() + .creating_worktree = true; + let old_sender = app.app_event_tx.clone(); + let connected = reconnect( + app.app_server_target.clone(), + app.config.clone(), + app.local_settings.clone(), + Some(id), + /*remote_cwd*/ None, + transport, + ReconnectPresentation::Conversation, + ) + .await?; + let paused = call_mcp().send().await?.text().await?; + assert!( + paused.contains("TUI is reconnecting; tool was not sent"), + "{paused}" + ); + app.finish_reconnect(&mut tui, &mut session, &mut events, connected, "2.1.0") + .await?; + assert!(app.pending_server_profiles.is_empty()); + assert!(!app.pending_managed_worktree_creation); + assert!( + !app.agents_overview + .view_state + .lock() + .unwrap() + .creating_worktree + ); + assert!(!app.reconnect.offline); + assert!(!app.thread_unavailable(id)); + assert_eq!(app.last_subagent_backfill_attempt, None); + assert!( + app.rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation + ) + .is_some() + ); + assert!(app.agent_navigation.begin_picker_refresh(id).is_some()); + assert!( + !app.agent_navigation + .finish_picker_refresh(id, stale_picker_refresh) + ); + // Let the rebound timer become due without depending on machine uptime. + tokio::time::pause(); + tokio::time::advance(recap::RECAP_DELAY).await; + tokio::time::resume(); + let mut deferred = Vec::new(); + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + loop { + let event = events.recv().await.expect("rebound recap event"); + if matches!(event, AppEvent::CheckRecap { thread_id } if thread_id == id) { + break; + } + deferred.push(event); + } + }) + .await?; + for event in deferred { + app.app_event_tx.send(event); + } + let ready = call_mcp().send().await?.text().await?; + assert!(ready.contains("threads"), "{ready}"); + assert_eq!( + app.chat_widget.remote_connection, + crate::status::remote_connection::remote_connection_status_value( + &app.app_server_target, + Some("2.0.0") + ) + ); + assert!(old_sender.app_event_tx.is_closed()); + assert!(session.task_tools_available(id)); + assert_eq!(app.current_displayed_thread_id(), Some(id)); + assert_eq!( + app.chat_widget.effective_collaboration_mode(), + expected_mode + ); + assert!(app.model_catalog.collaboration_modes.is_empty()); + assert!( + app.chat_widget + .model_catalog() + .collaboration_modes + .is_empty() + ); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + if edit_offline { + "old queued input" + } else { + "kept draft" + } + ); + let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + let notices = history + .lines() + .filter(|line| { + line.contains("Reconnected.") || line.contains("background Codex service") + }) + .collect::>() + .join("\n"); + if deferred_notice { + assert_snapshot!(notices, @r###" +• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused. +⚠ A background Codex service is running v2.0.0, older than your Codex CLI +"###); + } else { + insta::allow_duplicates! { + assert_snapshot!(notices, @"• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused."); + } + } + assert_eq!(history.matches("only once").count(), 1); + if recovered_queue { + assert!(app.chat_widget.has_queued_follow_up_messages()); + assert!(!app.chat_widget.maybe_send_next_queued_input()); + if edit_offline { + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)), + ) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "unacknowledged prompt" + ); + assert!(ops.try_recv().is_err()); + } else { + assert_snapshot!( + "restored_conversation", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + } + } else { + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.chat_widget + .restore_user_message_to_composer("fresh follow-up".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + while let Ok(event) = events.try_recv() { + app.handle_event(&mut tui, &mut session, event).await?; + } + } + session.shutdown().await?; + let methods = server.await??; + assert_eq!( + methods + .iter() + .filter(|method| *method == "thread/resume") + .count(), + 2 + ); + assert_eq!( + methods + .iter() + .filter(|method| *method == "turn/start") + .count(), + usize::from(!recovered_queue) + ); + } + Ok(()) +} + +#[tokio::test] +async fn reconnect_reconciles_offscreen_pending_profile_before_restoring_permissions() -> Result<()> +{ + let (mut app, mut events, _) = make_test_app_with_channels().await; + let primary = ThreadId::new(); + let displayed = ThreadId::new(); + let cwd = app.config.cwd.to_path_buf(); + app.config + .permissions + .set_permission_profile(PermissionProfile::Disabled)?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + app.primary_thread_id = Some(primary); + app.active_thread_id = Some(displayed); + for id in [primary, displayed] { + let mut session = test_thread_session(id, cwd.clone()); + session.permission_profile = PermissionProfile::Disabled; + app.ensure_thread_channel(id) + .store + .lock() + .await + .set_session(session.clone(), Vec::new()); + if id == primary { + app.primary_session_configured = Some(session); + app.upsert_agent_picker_thread( + id, /*agent_nickname*/ None, /*agent_role*/ None, + /*is_closed*/ false, + ); + } else { + app.chat_widget.handle_thread_session(session); + } + } + app.pending_server_profiles.insert( + primary, + PermissionProfileSelection { + profile_id: "server-only".into(), + approval_policy: None, + approvals_reviewer: None, + display_label: "server-only".into(), + }, + ); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?, + }; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| { + let id = request.params.as_ref().and_then(|params| params["threadId"].as_str()); + let thread = |id: ThreadId| json!({ + "id": id, "sessionId": id, "preview": "task", "ephemeral": false, + "modelProvider": "test-provider", "createdAt": 1, "updatedAt": 2, + "status": {"type": "idle"}, "cwd": cwd, "cliVersion": "0.0.0", + "source": "cli", "turns": [] + }); + std::future::ready(Some(match request.method.as_str() { + "thread/resume" => { + let pending = id == Some(primary.to_string().as_str()); + json!({"result": {"thread": thread(if pending { primary } else { displayed }), + "model": "gpt-test", "modelProvider": "test-provider", "cwd": cwd, + "approvalPolicy": if pending { "on-request" } else { "never" }, + "approvalsReviewer": "user", + "sandbox": {"type": if pending { "readOnly" } else { "dangerFullAccess" }}, + "activePermissionProfile": if pending { Some(json!({"id": "server-only"})) } else { None }, + "reasoningEffort": null}}) + } + "thread/read" => json!({"result": {"thread": thread(primary)}}), + "turn/start" => { + let params = request.params.as_ref().unwrap(); + assert_eq!(params["permissions"], "server-only"); + assert_eq!(params["approvalPolicy"], "on-request"); + assert_eq!(params["sandboxPolicy"], json!(null)); + json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}}) + } + "thread/list" | "thread/loaded/list" => + json!({"result": {"data": [], "nextCursor": null}}), + "thread/goal/get" => json!({"result": {"goal": null}}), + method => panic!("unexpected reconnect request: {method}"), + })) + }) + .await + }); + let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.begin_reconnect(); + let connected = reconnect( + app.app_server_target.clone(), + app.config.clone(), + app.local_settings.clone(), + Some(displayed), + /*remote_cwd*/ None, + session.thread_tool_transport(), + ReconnectPresentation::Conversation, + ) + .await?; + app.finish_reconnect( + &mut tui, + &mut session, + &mut events, + connected, + CODEX_CLI_VERSION, + ) + .await?; + assert!(app.pending_server_profiles.contains_key(&primary)); + + app.select_agent_thread(&mut tui, &mut session, primary) + .await?; + assert!(!app.thread_unavailable(primary)); + assert!(!app.pending_server_profiles.contains_key(&primary)); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + &PermissionProfile::read_only() + ); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile() + .as_ref() + .map(|profile| profile.id.as_str()), + Some("server-only") + ); + app.chat_widget + .restore_user_message_to_composer("safe follow-up".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + while let Ok(event) = events.try_recv() { + if matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })) { + app.handle_event(&mut tui, &mut session, event).await?; + } + } + session.shutdown().await?; + let methods = server.await??; + assert_eq!( + methods + .iter() + .filter(|method| *method == "turn/start") + .count(), + 1 + ); + Ok(()) +} + +#[tokio::test] +async fn reconnect_exhaustion_and_unknown_initial_thread_stay_offline() -> Result<()> { + let (mut app, _, _) = make_test_app_with_channels().await; + let listener = TcpListener::bind("127.0.0.1:0").await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?, + }; + drop(listener); + tokio::time::pause(); + let start = tokio::time::Instant::now(); + for id in [Some(ThreadId::new()), None] { + assert!( + reconnect( + app.app_server_target.clone(), + app.config.clone(), + app.local_settings.clone(), + id, + /*remote_cwd*/ None, + crate::dynamic_tools_mcp::ThreadToolTransport::Dynamic, + ReconnectPresentation::Conversation + ) + .await + .is_err() + ); + } + assert!((15..=65).contains(&start.elapsed().as_secs())); + app.begin_reconnect(); + app.chat_widget.reconnect_failed(); + assert_snapshot!( + "reconnect_failed", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + tokio::time::resume(); + Ok(()) +} + +#[tokio::test] +async fn reconnect_allows_slow_hydration_but_bounds_a_stalled_server() -> Result<()> { + for delay in [15, 150] { + let (mut app, mut events, _) = make_test_app_with_channels().await; + app.config.model = Some("gpt-test".into()); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: format!("ws://{}", listener.local_addr()?), + auth_token: None, + }; + let id = ThreadId::new(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + serve_reconnect_requests( + tokio_tungstenite::accept_async(stream).await?, + move |request| async move { + assert_eq!(request.method, "thread/resume"); + tokio::time::pause(); + tokio::time::sleep(Duration::from_secs(delay)).await; + tokio::time::resume(); + Some(json!({"error": {"code": -32600, "message": "thread no longer exists"}})) + }, + ) + .await + }); + let start = tokio::time::Instant::now(); + let result = reconnect( + AppServerTarget::Remote { + endpoint: endpoint.clone(), + }, + app.config.clone(), + app.local_settings.clone(), + Some(id), + /*remote_cwd*/ None, + crate::dynamic_tools_mcp::ThreadToolTransport::Dynamic, + ReconnectPresentation::Conversation, + ) + .await; + let elapsed = start.elapsed().as_secs(); + if delay == 15 { + app.app_server_target = AppServerTarget::Remote { endpoint }; + app.active_thread_id = Some(id); + app.primary_thread_id = Some(id); + let cached = test_thread_session(id, app.config.cwd.to_path_buf()); + app.ensure_thread_channel(id) + .store + .lock() + .await + .set_session(cached, Vec::new()); + app.chat_widget + .restore_user_message_to_composer("unavailable draft".into()); + app.begin_reconnect(); + let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.finish_reconnect( + &mut tui, + &mut session, + &mut events, + result?, + CODEX_CLI_VERSION, + ) + .await?; + assert!(app.thread_unavailable(id)); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "unavailable draft" + ); + let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + let history = &history[history.find("• This conversation is unavailable").unwrap()..]; + assert_snapshot!( + "unavailable_conversation", + format!( + "{history}\n{}", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + .replace( + &test_path_buf("/tmp/project").display().to_string(), + "/tmp/project" + ) + ); + session.shutdown().await?; + assert!((15..120).contains(&elapsed), "{elapsed}"); + let methods = server.await??; + assert_eq!( + methods + .iter() + .filter(|method| *method == "initialize") + .count(), + 1 + ); + } else { + tokio::time::resume(); + assert!(result.is_err()); + assert_eq!(elapsed, 120); + server.abort(); + } + } + Ok(()) +} + +pub(super) async fn drain_history( + app: &mut App, + tui: &mut tui::Tui, + session: &mut AppServerSession, + events: &mut mpsc::UnboundedReceiver, +) -> Result { + while let Ok(event) = events.try_recv() { + assert!(!matches!( + event, + AppEvent::CodexOp(AppCommand::UserTurn { .. }) + )); + if matches!( + event, + AppEvent::InsertHistoryCell(_) + | AppEvent::BeginThreadSwitchHistoryReplayBuffer + | AppEvent::EndInitialHistoryReplayBuffer + ) { + app.handle_event(tui, session, event).await?; + } + } + Ok(lines_to_single_string( + &app.transcript_cells + .iter() + .flat_map(|cell| cell.display_lines(/*width*/ 80)) + .collect::>(), + )) +} diff --git a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..2e82c78975cc2167e94fa45e9f7592e01b8f4b96 --- /dev/null +++ b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs @@ -0,0 +1,102 @@ +use super::*; +use app_test_support::create_fake_rollout; +use codex_utils_absolute_path::test_support::PathExt; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn fork_current_session_preserves_conversation_ultra() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); + let source_thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + app.chat_widget.handle_thread_session(ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::Ultra), + ..test_thread_session(source_thread_id, test_path_buf("/tmp/project")) + }); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ForkCurrentSession { name: None }, + )) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert_ne!(app.chat_widget.thread_id(), Some(source_thread_id)); + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn switching_from_ultra_thread_restores_configured_plan_effort() { + let mut app = make_test_app().await; + app.config.plan_mode_reasoning_effort = Some(ReasoningEffortConfig::High); + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + let ultra_session = ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::Ultra), + ..test_thread_session(ThreadId::new(), test_path_buf("/tmp/ultra")) + }; + let normal_session = ThreadSessionState { + windows_sandbox_host: crate::app::WindowsSandboxHost::Local, + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::Medium), + ..test_thread_session(ThreadId::new(), test_path_buf("/tmp/normal")) + }; + + app.replay_thread_snapshot( + ThreadEventSnapshot { + delegated_turns: Vec::new(), + session: Some(ultra_session), + turns: Vec::new(), + events: Vec::new(), + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + app.replay_thread_snapshot( + ThreadEventSnapshot { + delegated_turns: Vec::new(), + session: Some(normal_session), + turns: Vec::new(), + events: Vec::new(), + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + + assert_eq!( + app.chat_widget.active_collaboration_mode_kind(), + ModeKind::Plan + ); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); +} diff --git a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1daac6c13ecaaf303290424f568479c4b2f20c4a --- /dev/null +++ b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs @@ -0,0 +1,98 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn agents_navigation_requires_local_daemon() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = start_config_write_test_app_server(&app).await?; + let endpoint = crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }; + for target in [ + AppServerTarget::Embedded, + AppServerTarget::Remote { + endpoint: endpoint.clone(), + }, + AppServerTarget::LocalDaemon { endpoint }, + ] { + let enabled = matches!(target, AppServerTarget::LocalDaemon { .. }); + app.app_server_target = target; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + while events.try_recv().is_ok() {} + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::Left.into()), + ) + .await?; + if enabled { + let event = events.try_recv()?; + assert_matches!(event, AppEvent::OpenAgentsOverview); + app.handle_event(&mut tui, &mut app_server, event).await?; + assert!(!app.chat_widget.no_modal_or_popup_active()); + } else { + assert!(events.try_recv().is_err()); + assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("for agents")); + } + } + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn pending_windows_sandbox_setup_blocks_thread_replacement() -> Result<()> { + use crate::app_event::WindowsSandboxEnableMode; + + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.chat_widget.windows_sandbox_local_server = true; + while events.try_recv().is_ok() {} + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let current = ThreadId::new(); + let other = ThreadId::new(); + app.active_thread_id = Some(current); + app.primary_thread_id = Some(current); + let preset = codex_utils_approval_presets::builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + app.windows_sandbox.pending_setup = Some((WindowsSandboxEnableMode::Elevated, preset, None)); + let prompt = crate::chatwidget::create_initial_user_message( + Some("review this".to_string()), + Vec::new(), + Vec::new(), + ); + app.chat_widget.initial_user_message = prompt.clone(); + + app.select_agent_thread(&mut tui, &mut app_server, other) + .await?; + assert_eq!(app.active_thread_id, Some(current)); + assert_eq!(app.chat_widget.initial_user_message, prompt); + let cell = match events.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected setup navigation message, got {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 100)); + insta::assert_snapshot!(rendered, @"• Finish Windows sandbox setup before switching threads."); + + app.chat_widget.initial_user_message = None; + app.run_agents_overview_action( + &mut tui, + &mut app_server, + current, + crate::app_event::AgentsOverviewAction::Archive, + ) + .await?; + assert_eq!(app.active_thread_id, Some(current)); + assert!(events.try_recv().is_err()); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/backend_banner_fallback_tests.rs b/codex-rs/tui/src/app/tests/backend_banner_fallback_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e62bd1d723b852be45652d1515cca263edc45cfe --- /dev/null +++ b/codex-rs/tui/src/app/tests/backend_banner_fallback_tests.rs @@ -0,0 +1,503 @@ +use super::*; +use crate::chatwidget::UserMessage; +use crate::chatwidget::tests::helpers::normalize_snapshot_paths; +use crate::chatwidget::tests::helpers::set_fast_mode_test_catalog_for_models; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use pretty_assertions::assert_eq; +use serde_json::json; + +pub(super) fn fallback_response() -> GetAccountRateLimitsResponse { + serde_json::from_value(json!({ + "accountId": "workspace-a", "rateLimits": {}, + "rateLimitUpsell": { + "banner_type": "model_recovery", "model_slug": "gpt-5.5", + "blocked_model_slug": "gpt-5.5", + "fallback_model_slugs": ["unavailable-model", "gpt-5.5", "gpt-5.6-terra"], + "title": "Selected model usage exhausted", + "description": "Add credits to use this model again.", + "presentation": "dismissible", + "ctas": [{"action": "view_usage", "label": "View usage"}] + } + })) + .unwrap() +} + +fn configure_fallback_model(app: &mut App) { + set_chatgpt_auth(&mut app.chat_widget); + set_fast_mode_test_catalog_for_models(&mut app.chat_widget, "gpt-5.5", "gpt-5.6-terra"); + app.model_catalog = app.chat_widget.model_catalog(); + app.chat_widget.set_model("gpt-5.5"); +} + +pub(super) async fn start_fallback_thread( + app: &mut App, +) -> Result { + let (mut server, requests, proxy) = session_lifecycle_requests::start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let started = server.start_thread(&app.config).await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + configure_fallback_model(app); + Ok((server, requests, proxy)) +} + +#[tokio::test] +async fn backend_banner_fallback_updates_task_settings_and_keeps_notice() -> Result<()> { + for mode_kind in [ModeKind::Default, ModeKind::Plan] { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?; + let thread_id = app.active_thread_id.expect("active fallback thread"); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + if mode_kind == ModeKind::Plan { + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + app.chat_widget + .set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); + } + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ true); + app.chat_widget + .set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); + let mut expected_mode = app.chat_widget.effective_collaboration_mode(); + assert_eq!(expected_mode.mode, mode_kind); + expected_mode.settings.model = "gpt-5.6-terra".into(); + expected_mode.settings.reasoning_effort = Some(ReasoningEffortConfig::Medium); + let default_model = app.config.model.clone(); + let default_plan_effort = app.config.plan_mode_reasoning_effort.clone(); + let config_path = app.config.codex_home.join("config.toml"); + let saved_config = std::fs::read(&config_path).ok(); + while events.try_recv().is_ok() {} + while ops.try_recv().is_ok() {} + requests.lock().unwrap().clear(); + let response = fallback_response(); + let generation = app.rate_limit_hard_stop_generation; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::StatusCommand { request_id: 0 }, + hard_stop_generation: generation, + result: Ok(response.clone()), + }, + ) + .await?; + + let actual_mode = app.chat_widget.effective_collaboration_mode(); + assert_eq!(actual_mode, expected_mode); + assert_eq!(app.chat_widget.current_service_tier(), None); + let sent = requests.lock().unwrap().clone(); + let methods: Vec<_> = sent.iter().map(|request| request.method.as_str()).collect(); + assert_eq!(methods, ["thread/settings/update"]); + assert_eq!( + serde_json::from_value::(sent[0].params.clone().unwrap())?, + ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + model: Some("gpt-5.6-terra".into()), + effort: Some(ReasoningEffortConfig::Medium), + collaboration_mode: Some(expected_mode.clone()), + service_tier: Some(Some("default".into())), + ..Default::default() + } + ); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 72); + assert!(rendered.contains("Selected model usage exhausted")); + if mode_kind == ModeKind::Default { + insta::assert_snapshot!( + "backend_banner_after_fallback", + normalize_snapshot_paths(rendered) + ); + } + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + assert!(queued.iter().any(|event| matches!(event, AppEvent::OpenUrlInBrowser { url } if url == "https://chatgpt.com/codex/settings/usage"))); + let notices = queued + .iter() + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::>(); + assert_eq!(notices.len(), 1); + insta::assert_snapshot!("backend_banner_fallback_notice", notices[0]); + assert!(!queued.iter().any(|event| matches!( + event, + AppEvent::PersistModelSelection { .. } | AppEvent::CodexOp(_) + ))); + assert!(ops.try_recv().is_err(), "switch must not replay a turn"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.chat_widget.update_backend_banner(&response); + app.apply_backend_banner_fallback(&mut server).await; + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 72) + .contains("Selected model usage exhausted") + ); + let mut healthy = response; + healthy.rate_limit_upsell = None; + app.chat_widget.update_backend_banner(&healthy); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.6-terra"); + assert_eq!(requests.lock().unwrap().len(), 1); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::InsertHistoryCell(_))) + ); + assert_eq!(app.config.model, default_model); + assert_eq!(app.config.plan_mode_reasoning_effort, default_plan_effort); + assert_eq!(std::fs::read(&config_path).ok(), saved_config); + if mode_kind == ModeKind::Plan { + let chat = &mut app.chat_widget; + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + } + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("continue")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(next_user_turn_op(&mut ops), AppCommand::UserTurn { model, collaboration_mode: Some(mode), .. } + if model == "gpt-5.6-terra" && mode == expected_mode); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn backend_banner_fallback_uses_current_task_and_accepted_generation() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?; + let read_generation = app.rate_limit_hard_stop_generation; + let second = server.start_thread(&app.config).await?; + let active_id = second.session.thread_id; + let mut tui = crate::tui::test_support::make_test_tui()?; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + app.store_active_thread_receiver().await; + app.active_thread_id = None; + app.enqueue_primary_thread_session(second.session, second.turns) + .await?; + configure_fallback_model(&mut app); + requests.lock().unwrap().clear(); + app.rate_limit_hard_stop_generation = read_generation.wrapping_add(1); + for generation in [read_generation, app.rate_limit_hard_stop_generation] { + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::StatusCommand { request_id: 0 }, + hard_stop_generation: generation, + result: Ok(fallback_response()), + }, + ) + .await?; + if generation == read_generation { + assert_eq!(app.chat_widget.current_model(), "gpt-5.5"); + assert!(requests.lock().unwrap().is_empty()); + } + } + let sent = requests.lock().unwrap().clone(); + assert_eq!(sent.len(), 1); + assert_eq!( + sent[0].params.as_ref().unwrap()["threadId"], + active_id.to_string() + ); + assert_eq!(app.chat_widget.current_model(), "gpt-5.6-terra"); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_fallback_keeps_existing_recovery_without_candidate() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = start_fallback_thread(&mut app).await?; + requests.lock().unwrap().clear(); + for (replacement, expect_legacy_banner) in [ + (json!([]), true), + (json!(["unavailable-model"]), false), + (json!(["gpt-5.5"]), false), + ] { + let mut response = fallback_response(); + response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] = replacement; + app.chat_widget.update_backend_banner(&response); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.5"); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 72); + assert_eq!(rendered.contains("View usage"), expect_legacy_banner); + if !expect_legacy_banner { + insta::assert_snapshot!( + "backend_banner_fallback_unavailable", + normalize_snapshot_paths(rendered) + ); + } + } + app.chat_widget.set_model("gpt-5.6-terra"); + app.chat_widget.update_backend_banner(&fallback_response()); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.6-terra"); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 72) + .contains("Selected model usage exhausted") + ); + assert!(requests.lock().unwrap().is_empty()); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_fallback_handles_settings_failure_and_queued_manual_selection() -> Result<()> +{ + use futures::SinkExt; + use futures::StreamExt; + use tokio_tungstenite::tungstenite::Message; + + for (use_reserve, error_code) in [false, true] + .into_iter() + .flat_map(|reserve| [Some(-32601), Some(-32603), None].map(|error| (reserve, error))) + { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let id = ThreadId::new(); + app.active_thread_id = Some(id); + app.chat_widget + .handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf())); + if use_reserve { + app.primary_session_configured = + Some(test_thread_session(id, app.config.cwd.to_path_buf())); + super::luna_reserve_recovery_tests::configure_reserve_catalog(&mut app); + } else { + configure_fallback_model(&mut app); + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let codex_home = app.config.codex_home.display().to_string(); + let manual_selection_tx = app.app_event_tx.clone(); + let fake = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + let mut socket = tokio_tungstenite::accept_async(stream).await?; + let mut settings_requests = 0; + while let Some(frame) = socket.next().await { + let Message::Text(text) = frame? else { + continue; + }; + let request: serde_json::Value = serde_json::from_str(&text)?; + if request["id"].is_null() { + continue; + } + let response = if request["method"] == "initialize" { + json!({"id": request["id"], "result": {"userAgent": "test", "codexHome": codex_home}}) + } else { + assert_eq!(request["method"], "thread/settings/update"); + settings_requests += 1; + if let Some(error_code) = error_code { + json!({"id": request["id"], "error": {"code": error_code, "message": "settings unavailable"}}) + } else { + if settings_requests == 1 { + // A manual selection arriving while the RPC is pending must win afterward. + manual_selection_tx.send(AppEvent::UpdateModel("gpt-5.5".into())); + } + json!({"id": request["id"], "result": {}}) + } + }; + socket + .send(Message::Text(response.to_string().into())) + .await?; + } + Result::<()>::Ok(()) + }); + let client = crate::connect_remote_app_server(crate::RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: None, + }) + .await?; + let mut server = AppServerSession::new( + client, + crate::app_server_session::ThreadParamsMode::Embedded, + ); + while events.try_recv().is_ok() {} + let pending_turn = if use_reserve && error_code.is_some() { + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("queued before usage reply")); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + Some( + std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + .expect("turn already queued before the failed switch"), + ) + } else { + None + }; + let response = if use_reserve { + super::luna_reserve_recovery_tests::reserve_response() + } else { + fallback_response() + }; + app.chat_widget.update_backend_banner(&response); + app.apply_backend_banner_fallback(&mut server).await; + let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + let switch_notices = queued + .iter() + .filter(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + lines_to_single_string(&cell.display_lines(/*width*/ 80)) + .contains("Automatically switched") + } + _ => false, + }) + .count(); + assert_eq!(switch_notices, usize::from(error_code.is_none())); + if error_code.is_none() { + assert_eq!( + app.chat_widget.current_model(), + if use_reserve { + "gpt-reserve" + } else { + "gpt-5.6-terra" + } + ); + let manual_selection = queued + .into_iter() + .find(|event| matches!(event, AppEvent::UpdateModel(_))) + .unwrap(); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event(&mut tui, &mut server, manual_selection) + .await?; + } + assert_eq!(app.chat_widget.current_model(), "gpt-5.5"); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 72).contains("View usage")); + if use_reserve && error_code.is_some() { + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 90); + assert!(rendered.contains("Add credits")); + assert!(!rendered.contains("You’re now using Luna")); + if error_code == Some(-32601) { + insta::assert_snapshot!( + "luna_reserve_settings_unavailable", + normalize_snapshot_paths(rendered) + ); + } + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert!( + std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenUrlInBrowser { .. })) + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event(&mut tui, &mut server, pending_turn.unwrap()) + .await?; + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued before usage reply"] + ); + app.chat_widget.finish_rate_limit_recovery(); + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("after recovery")); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued before usage reply", "after recovery"] + ); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + ); + } + server.shutdown().await?; + fake.await??; + } + Ok(()) +} + +#[tokio::test] +async fn backend_banner_fallback_preserves_permissions_for_first_eligible_cyber_model() -> Result<()> +{ + let mut app = make_test_app().await; + assert!( + app.apply_permission_profile_selection(PermissionProfileSelection { + profile_id: codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY.into(), + approval_policy: Some(AskForApproval::UnlessTrusted), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".into(), + }) + .await + ); + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + configure_fallback_model(&mut app); + let mut models = app.model_catalog.try_list_models()?; + let mut cyber_model = models + .iter() + .find(|model| model.model == "gpt-5.6-terra") + .unwrap() + .clone(); + // Like the manual-selection tests, classify a fixture model through catalog metadata. + cyber_model.model = "cyber-test-model".into(); + cyber_model.model_specialty = Some(MODEL_SPECIALTY_CYBER.into()); + models.push(cyber_model.clone()); + app.model_catalog = Arc::new(ModelCatalog::new(models)); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + init.model_catalog = app.model_catalog.clone(); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + app.chat_widget + .handle_thread_session(app.primary_session_configured.clone().unwrap()); + app.chat_widget.set_model("gpt-5.5"); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + let mut response = fallback_response(); + response.rate_limit_upsell.as_mut().unwrap()["fallback_model_slugs"] = json!([ + "unavailable-model", + "gpt-5.5", + cyber_model.model, + "gpt-5.6-terra" + ]); + app.chat_widget.update_backend_banner(&response); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), cyber_model.model); + let settings = next_thread_settings_updated(&mut server, thread_id) + .await + .thread_settings; + assert_eq!( + ( + settings.model, + settings.effort, + settings.approval_policy, + settings.approvals_reviewer.to_core(), + settings.active_permission_profile.unwrap().id + ), + ( + cyber_model.model, + Some(ReasoningEffortConfig::Medium), + AskForApproval::UnlessTrusted, + ApprovalsReviewer::User, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string() + ), + ); + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/backend_banner_recovery_tests.rs b/codex-rs/tui/src/app/tests/backend_banner_recovery_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..60d8639af642bb03b5053ec375cebb131b066a19 --- /dev/null +++ b/codex-rs/tui/src/app/tests/backend_banner_recovery_tests.rs @@ -0,0 +1,567 @@ +use super::*; +use crate::app::rate_limit_refresh::RateLimitReadStatus; +use crate::app::rate_limit_refresh::RateLimitRefreshOutcome; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn response_with_banner() -> GetAccountRateLimitsResponse { + serde_json::from_value(json!({ + "accountId":"workspace-a", "rateLimitUpsell":{ + "banner_type":"selected_model_limit", "model_slug":"test-model-a", "presentation":"inline", + "title":"Selected model usage exhausted", "description":"Switch to another model or contact your owner.", + "ctas":[{"action":"notify_owner","label":"Notify owner"}] + }, + "rateLimits":{}, "rateLimitsByLimitId":null,"rateLimitResetCredits":null + })).unwrap() +} + +async fn next_rate_limits_loaded( + events: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Result { + Ok( + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), async { + loop { + let event = events.recv().await.expect("app event channel"); + if matches!(event, AppEvent::RateLimitsLoaded { .. }) { + break event; + } + } + }) + .await?, + ) +} + +#[tokio::test] +async fn luna_reserve_periodic_refresh_adapts_without_an_experiment_banner() -> Result<()> { + let backend = MockServer::start().await; + let home = tempdir()?; + write_chatgpt_auth( + home.path(), + ChatGptAuthFixture::new("local-test-token") + .account_id("workspace-a") + .chatgpt_user_id("user-a") + .plan_type("plus"), + AuthCredentialsStoreMode::File, + ) + .expect("write synthetic auth"); + std::fs::write( + home.path().join("config.toml"), + format!( + "chatgpt_base_url = {:?}\ncli_auth_credentials_store = \"file\"\n", + backend.uri() + ), + )?; + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.chatgpt_base_url = backend.uri(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(home.path().abs()); + set_chatgpt_auth(&mut app.chat_widget); + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + for (used, seconds) in [(10, 60), (75, 30), (90, 15), (99, 5), (20, 60)] { + backend.reset().await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "account_id": "workspace-a", "user_id": "user-a", "plan_type": "plus", + "rate_limit": {"allowed": true, "limit_reached": false, + "primary_window": {"used_percent": used, "limit_window_seconds": 18000, + "reset_after_seconds": 1800, "reset_at": 2000000000}}, + "rate_limit_reset_credits": {"available_count": 1} + }))) + .expect(/*r*/ 1) + .mount(&backend) + .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .respond_with(ResponseTemplate::new(/*s*/ 200)) + .expect(/*r*/ 0) + .mount(&backend) + .await; + app.refresh_rate_limits(&session, RateLimitRefreshOrigin::Periodic); + // A second timer tick while this RPC is pending must not add another request. + app.refresh_rate_limits(&session, RateLimitRefreshOrigin::Periodic); + assert!( + app.rate_limit_refresh_state + .poll_deadline(Duration::from_secs(/*secs*/ 60)) + .is_none() + ); + let loaded = next_rate_limits_loaded(&mut events).await?; + assert_matches!(&loaded, AppEvent::RateLimitsLoaded { result: Ok(_), .. }); + let before = std::time::Instant::now(); + app.handle_event(&mut tui, &mut session, loaded).await?; + let after = std::time::Instant::now(); + let interval = app.chat_widget.rate_limit_refresh_interval().unwrap(); + assert_eq!(interval, Duration::from_secs(seconds)); + let deadline = app + .rate_limit_refresh_state + .poll_deadline(interval) + .unwrap(); + assert!(deadline >= before + interval && deadline <= after + interval); + let requests = backend.received_requests().await.unwrap(); + let usage = requests + .iter() + .find(|request| request.url.path() == "/api/codex/usage") + .unwrap(); + assert!(usage.headers.contains_key("x-openai-codex-luna-reserve")); + backend.verify().await; + } + session.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_limit_error_refreshes_again_after_intervening_rolling_hard_stop() +-> Result<()> { + let server = MockServer::start().await; + let home = tempdir()?; + write_chatgpt_auth( + home.path(), + ChatGptAuthFixture::new("local-test-token") + .account_id("workspace-a") + .chatgpt_user_id("user-a") + .plan_type("team"), + AuthCredentialsStoreMode::File, + ) + .expect("write synthetic auth"); + std::fs::write( + home.path().join("config.toml"), + format!( + "chatgpt_base_url = {:?}\ncli_auth_credentials_store = \"file\"\n", + server.uri() + ), + )?; + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.chatgpt_base_url = server.uri(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(home.path().abs()); + set_chatgpt_auth(&mut app.chat_widget); + app.chat_widget.set_model("test-model-a"); + let mut healthy = response_with_banner(); + healthy.rate_limit_upsell = None; + app.chat_widget.update_backend_banner(&healthy); + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(std::time::Duration::from_millis(/*millis*/ 100)) + .set_body_json(json!({ + "account_id":"workspace-a","user_id":"user-a","plan_type":"team", + "rate_limit":{"allowed":true,"limit_reached":false}, + "rate_limit_upsell":response_with_banner().rate_limit_upsell, + "rate_limit_reset_credits":{"available_count":0} + })), + ) + .expect(3) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"available_count":0,"credits":[]})), + ) + .expect(3) + .mount(&server) + .await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + while events.try_recv().is_ok() {} + app.chat_widget.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + misalignment: None, + message: "credits exhausted".into(), + codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), + additional_details: None, + }, + will_retry: false, + thread_id: "thread-a".into(), + turn_id: "turn-a".into(), + }), + /*replay_kind*/ None, + ); + let recovery = loop { + let event = events + .try_recv() + .expect("limit error should request recovery without a command"); + if matches!( + event, + AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::Recovery + } + ) { + break event; + } + }; + app.handle_event(&mut tui, &mut session, recovery).await?; + app.handle_event( + &mut tui, + &mut session, + AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::Recovery, + }, + ) + .await?; + let mut rolling = response_with_banner().rate_limits; + rolling.rate_limit_reached_type = + Some(codex_app_server_protocol::RateLimitReachedType::WorkspaceMemberCreditsDepleted); + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountRateLimitsUpdated( + codex_app_server_protocol::AccountRateLimitsUpdatedNotification { + rate_limits: rolling.clone(), + }, + ), + )), + ) + .await; + let loaded = next_rate_limits_loaded(&mut events).await?; + assert!( + matches!(&loaded, AppEvent::RateLimitsLoaded { result: Ok(_), .. }), + "{loaded:?}" + ); + app.handle_event(&mut tui, &mut session, loaded).await?; + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + // No second inference Error or explicit refresh: the obsolete read starts its own replacement. + let loaded = next_rate_limits_loaded(&mut events).await?; + app.handle_event(&mut tui, &mut session, loaded).await?; + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 90); + assert!(rendered.contains("Selected model usage exhausted")); + assert!(rendered.contains("Notify owner")); + // A background operation can report the same hard stop after the full response omitted + // aggregate reached_type: included usage for other models is still available. + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountRateLimitsUpdated( + codex_app_server_protocol::AccountRateLimitsUpdatedNotification { + rate_limits: rolling, + }, + ), + )), + ) + .await; + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + let loaded = next_rate_limits_loaded(&mut events).await?; + app.handle_event(&mut tui, &mut session, loaded).await?; + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + app.chat_widget.set_model("test-model-b"); + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + session.shutdown().await?; + server.verify().await; + Ok(()) +} + +#[test] +fn backend_banner_recovery_follows_rolling_invalidation_without_another_error() { + let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default(); + let mut epoch = 0; + let first = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + epoch += 1; + assert_eq!( + refresh.finish(first.0, first.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::RefreshRecovery + ); +} + +#[tokio::test] +async fn backend_banner_rolling_only_recovery_holds_new_input() -> Result<()> { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + app.config.cwd.to_path_buf(), + )); + set_chatgpt_auth(&mut app.chat_widget); + app.chat_widget.set_model("test-model-a"); + let mut rolling = response_with_banner().rate_limits; + rolling.spend_control_reached = Some(true); + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountRateLimitsUpdated( + codex_app_server_protocol::AccountRateLimitsUpdatedNotification { + rate_limits: rolling, + }, + ), + )), + ) + .await; + app.chat_widget.apply_external_edit("follow-up".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["follow-up"] + ); + assert!( + !std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. })) + ); + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::Recovery, + hard_stop_generation: app.rate_limit_hard_stop_generation, + result: Ok(response_with_banner()), + }, + ) + .await?; + assert!( + matches!(next_user_turn_op(&mut ops), Op::UserTurn { items, .. } + if items == vec![UserInput::Text { text: "follow-up".into(), text_elements: Vec::new() }]) + ); + session.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_account_changes_invalidate_pending_recovery() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.set_model("test-model-a"); + let current = response_with_banner(); + app.chat_widget.update_backend_banner(¤t); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + let pending = app + .rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation, + ) + .unwrap(); + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountUpdated( + codex_app_server_protocol::AccountUpdatedNotification { + auth_mode: None, + plan_type: None, + }, + ), + )), + ) + .await; + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: pending.0, + origin: RateLimitRefreshOrigin::Recovery, + hard_stop_generation: pending.1, + result: Ok(current), + }, + ) + .await?; + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + assert!( + app.rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation + ) + .is_some() + ); + session.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_reset_redemption_rejects_pre_reset_content() -> Result<()> { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + app.config.cwd.to_path_buf(), + )); + app.chat_widget.set_model("test-model-a"); + let banner = response_with_banner(); + app.chat_widget.update_backend_banner(&banner); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + let pending = app + .rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation, + ) + .unwrap(); + app.chat_widget.hold_rate_limit_recovery(); + app.chat_widget.apply_external_edit("after reset".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["after reset"] + ); + let request_id = app.chat_widget.show_rate_limit_reset_consuming_popup(); + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitResetCreditConsumed { + request_id, + idempotency_key: "mock-reset".into(), + credit_id: None, + result: Ok( + codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse { + outcome: + codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome::Reset, + }, + ), + }, + ) + .await?; + let mut recovered = banner.clone(); + recovered.rate_limit_upsell = None; + recovered.rate_limit_reset_credits = + Some(codex_app_server_protocol::RateLimitResetCreditsSummary { + available_count: 0, + credits: None, + }); + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: 2, + origin: RateLimitRefreshOrigin::ResetConsume { request_id }, + hard_stop_generation: app.rate_limit_hard_stop_generation, + result: Ok(recovered), + }, + ) + .await?; + assert!( + matches!(next_user_turn_op(&mut ops), Op::UserTurn { items, .. } + if items == vec![UserInput::Text { text: "after reset".into(), text_elements: Vec::new() }]) + ); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: pending.0, + origin: RateLimitRefreshOrigin::Recovery, + hard_stop_generation: pending.1, + result: Ok(banner), + }, + ) + .await?; + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted") + ); + assert!( + app.rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::Recovery, + &mut app.rate_limit_hard_stop_generation + ) + .is_some() + ); + session.shutdown().await?; + Ok(()) +} + +#[test] +fn backend_banner_post_reset_read_preserves_newer_recovery_hold() { + let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default(); + let mut epoch = 0; + let old = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + epoch += 1; + refresh.invalidate_recovery(); + let reset = refresh + .start( + RateLimitRefreshOrigin::ResetConsume { request_id: 1 }, + &mut epoch, + ) + .unwrap(); + assert_eq!( + refresh.finish(old.0, old.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::Ignore + ); + assert!(refresh.has_pending_recovery()); + let newer = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + assert_eq!( + refresh.finish(reset.0, reset.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::Ignore + ); + assert!(refresh.has_pending_recovery()); + assert_eq!( + refresh.finish(newer.0, newer.1, epoch, RateLimitReadStatus::Failed), + RateLimitRefreshOutcome::Ignore + ); + assert!(!refresh.has_pending_recovery()); +} + +#[test] +fn backend_banner_recovery_coalesces_until_newer_epoch_can_be_read() { + let mut refresh = crate::app::rate_limit_refresh::RateLimitRefreshState::default(); + let mut epoch = 0; + let first = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + epoch += 1; // A newer rolling hard stop makes the in-flight response obsolete. + for _ in 0..10 { + assert_eq!( + refresh.start(RateLimitRefreshOrigin::Recovery, &mut epoch), + None + ); + } + assert_eq!( + refresh.finish(first.0, first.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::RefreshRecovery + ); + let next = refresh + .start(RateLimitRefreshOrigin::Recovery, &mut epoch) + .unwrap(); + assert_eq!( + refresh.finish(next.0, next.1, epoch, RateLimitReadStatus::Succeeded), + RateLimitRefreshOutcome::Apply + ); +} diff --git a/codex-rs/tui/src/app/tests/backend_banner_startup_tests.rs b/codex-rs/tui/src/app/tests/backend_banner_startup_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..68670ee73568683594816731f83a3fff2d8f086a --- /dev/null +++ b/codex-rs/tui/src/app/tests/backend_banner_startup_tests.rs @@ -0,0 +1,95 @@ +use super::backend_banner_fallback_tests::fallback_response; +use super::*; +use crate::chatwidget::UserMessage; +use crate::chatwidget::tests::helpers::set_fast_mode_test_catalog_for_models; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn backend_banner_fallback_applies_before_initial_and_queued_startup_prompts() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut config = app.config.clone(); + config.model = Some("gpt-5.5".into()); + let mut tui = crate::tui::test_support::make_test_tui()?; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + config.clone(), + Some(UserMessage::from("initial prompt")), + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + set_chatgpt_auth(&mut app.chat_widget); + set_fast_mode_test_catalog_for_models(&mut app.chat_widget, "gpt-5.5", "gpt-5.6-terra"); + app.model_catalog = app.chat_widget.model_catalog(); + app.pending_startup_thread_start = true; + app.chat_widget.set_model("gpt-5.5"); + app.chat_widget + .set_queue_submissions_until_session_configured(/*queue*/ true); + app.chat_widget + .apply_external_edit("queued follow-up".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let (mut server, requests, proxy) = session_lifecycle_requests::start_recording_app_server( + &config, /*blocked_thread_list*/ None, /*failed_thread_name*/ None, + ) + .await?; + let generation = app.rate_limit_hard_stop_generation; + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::StartupPrefetch { + reset_hint_request_id: 0, + }, + hard_stop_generation: generation, + result: Ok(fallback_response()), + }, + ) + .await?; + assert!(requests.lock().unwrap().is_empty()); + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 72) + .contains("Selected model usage exhausted") + ); + let started = server.start_thread(&config).await?; + let thread_id = started.session.thread_id; + requests.lock().unwrap().clear(); + while events.try_recv().is_ok() {} + app.handle_startup_thread_started(&mut server, Ok(started)) + .await?; + assert_eq!(app.active_thread_id, Some(thread_id)); + let submissions = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| { + if let AppEvent::CodexOp(AppCommand::UserTurn { model, items, .. }) = event { + Some((model, items)) + } else { + None + } + }) + .collect::>(); + assert_eq!( + submissions, + vec![( + "gpt-5.6-terra".into(), + vec![UserInput::Text { + text: "initial prompt".into(), + text_elements: Vec::new(), + }] + )] + ); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued follow-up"] + ); + assert_eq!( + requests + .lock() + .unwrap() + .iter() + .map(|request| request.method.as_str()) + .collect::>(), + vec!["thread/settings/update"] + ); + server.shutdown().await?; + proxy.await??; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..911af0c7f81462c9f6e26422c2c3d92bed256bb7 --- /dev/null +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -0,0 +1,594 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn external_writer_view_quits_with_escape_ctrl_c_or_q() -> Result<()> { + let (mut app, mut events, _operations) = make_test_app_with_channels().await; + app.chat_widget.show_external_writer_thread(); + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + while events.try_recv().is_ok() {} + + app.keymap.app.open_external_editor = vec![crate::key_hint::ctrl(KeyCode::Char('g'))]; + app.handle_key_event( + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), + ) + .await; + assert_eq!( + app.chat_widget.external_editor_state(), + ExternalEditorState::Closed + ); + assert!( + !events + .try_recv() + .is_ok_and(|event| matches!(event, AppEvent::LaunchExternalEditor)) + ); + + for key in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ] { + app.handle_key_event(&mut tui, &mut app_server, key).await; + assert!(matches!( + events.try_recv(), + Ok(AppEvent::Exit(ExitMode::Immediate)) + )); + } + Ok(()) +} + +#[tokio::test] +async fn external_writer_view_preserves_draft_from_keys_and_paste() -> Result<()> { + let (mut app, _events, _operations) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "wss://example.com/".to_string(), + auth_token: None, + }, + }; + app.ensure_thread_channel(thread_id).mark_external_writer(); + app.chat_widget.insert_str("Retained draft"); + app.chat_widget.show_external_writer_thread(); + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + for offline in [false, true] { + app.reconnect.offline = offline; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + ) + .await?; + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Paste("pasted".into())) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + } + + app.reconnect.offline = false; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + ) + .await?; + assert!(app.overlay.is_some()); + Ok(()) +} + +#[tokio::test] +async fn daemon_disconnect_exit_summary_includes_reconnect_and_stop_instructions() -> Result<()> { + let (mut app, _, _) = make_test_app_with_channels().await; + let thread_id = prepare_running_local_daemon(&mut app)?; + app.keymap.agents.stop = vec![crate::key_hint::plain(KeyCode::F(10))]; + let mut exit_info = app.exit_info(ExitReason::UserRequested); + exit_info.token_usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let output = exit_info + .format_exit_messages(/*color_enabled*/ false) + .join("\n") + .replace(&thread_id.to_string(), "THREAD_ID"); + assert_snapshot!("daemon_disconnect_exit", output); + Ok(()) +} + +#[tokio::test] +async fn remote_disconnect_exit_summary_does_not_require_a_local_rollout_or_print_credentials() { + let (mut app, _, _) = make_test_app_with_channels().await; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "wss://user:secret@example.com:443/?token=private#secret".to_string(), + auth_token: Some("secret-token".to_string()), + }, + }; + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + app.active_thread_id = Some(thread_id); + app.chat_widget.handle_thread_session(test_thread_session( + thread_id, + test_path_buf("/tmp/project"), + )); + let exit_info = app.exit_info(ExitReason::Fatal("connection lost".to_string())); + let lines = exit_info.format_exit_messages(/*color_enabled*/ false); + let command = shlex::split(lines[1].strip_prefix("Reconnect: ").unwrap()).unwrap(); + assert_eq!( + crate::resolve_remote_addr(&command[2]).unwrap(), + crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "wss://example.com/".to_string(), + auth_token: None, + } + ); + assert_snapshot!("remote_disconnect_exit", lines.join("\n")); +} + +#[tokio::test] +async fn embedded_exit_keeps_the_session_summary() { + let (mut app, _, _) = make_test_app_with_channels().await; + let thread_id = prepare_local_daemon_thread(&mut app).unwrap(); + app.app_server_target = AppServerTarget::Embedded; + let mut exit_info = app.exit_info(ExitReason::UserRequested); + exit_info.token_usage = TokenUsage { + output_tokens: 2, + total_tokens: 2, + ..Default::default() + }; + exit_info.resume_hint = Some(ResumableThread { + thread_id, + thread_name: None, + }); + let output = exit_info + .format_exit_messages(/*color_enabled*/ false) + .join("\n") + .replace(&thread_id.to_string(), "THREAD_ID"); + assert_snapshot!(output, @" + Token usage: total=2 input=0 output=2 + To continue this session, run: + codex resume THREAD_ID + "); +} + +fn prepare_local_daemon_thread(app: &mut App) -> Result { + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget.handle_thread_session(test_thread_session( + thread_id, + test_path_buf("/tmp/project"), + )); + Ok(thread_id) +} + +fn prepare_running_local_daemon(app: &mut App) -> Result { + let thread_id = prepare_local_daemon_thread(app)?; + app.chat_widget.handle_server_notification( + turn_started_notification(thread_id, "turn-1"), + /*replay_kind*/ None, + ); + Ok(thread_id) +} + +async fn open_running_task_exit_menu( + app: &mut App, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, +) { + app.handle_key_event( + tui, + app_server, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ) + .await; +} + +async fn prepare_background_exit_test( + app: &App, + app_event_rx: &mut mpsc::UnboundedReceiver, + op_rx: &mut mpsc::UnboundedReceiver, +) -> Result<(AppServerSession, tui::Tui)> { + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + Ok((app_server, crate::tui::test_support::make_test_tui()?)) +} + +#[tokio::test] +async fn daemon_ctrl_c_shows_background_exit_menu_and_escape_dismisses_it() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(!app.chat_widget.no_modal_or_popup_active()); + assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r" + Task is still running + Choose what happens to the current task. + + › 1. Cancel task Stop the current task and stay in Codex + 2. Run in background Exit Codex and leave the task running + 3. Exit Stop the current task and exit Codex + + Press enter to confirm or esc to go back + "); + + app.handle_key_event( + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ) + .await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(op_rx.try_recv().is_err()); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn cancel_task_interrupts_without_exiting() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let event = app_event_rx.try_recv()?; + assert_matches!( + event, + AppEvent::RunningTaskExit { + action: RunningTaskExitAction::CancelTask, + .. + } + ); + let control = app.handle_event(&mut tui, &mut app_server, event).await?; + + assert_matches!(control, AppRunControl::Continue); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn run_in_background_detaches_without_interrupting_main_or_side_threads() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let parent_thread_id = prepare_running_local_daemon(&mut app)?; + let side_thread_id = ThreadId::new(); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let event = app_event_rx.try_recv()?; + assert_matches!( + event, + AppEvent::RunningTaskExit { + action: RunningTaskExitAction::RunInBackground, + thread_id, + } if thread_id == parent_thread_id + ); + let control = app.handle_event(&mut tui, &mut app_server, event).await?; + + assert_matches!(control, AppRunControl::Exit(ExitReason::UserRequested)); + assert!(app.side_threads.contains_key(&side_thread_id)); + assert!(op_rx.try_recv().is_err()); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn exit_interrupts_before_requesting_shutdown() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.chat_widget + .set_feature_enabled(Feature::Goals, /*enabled*/ true); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session(started.session.clone()); + let goal = app_server + .thread_goal_set( + thread_id, + Some("test goal".to_string()), + Some(codex_app_server_protocol::ThreadGoalStatus::Paused), + /*token_budget*/ None, + ) + .await? + .goal; + app.chat_widget.handle_server_notification( + ServerNotification::ThreadGoalUpdated( + codex_app_server_protocol::ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal: codex_app_server_protocol::ThreadGoal { + status: codex_app_server_protocol::ThreadGoalStatus::Active, + ..goal + }, + }, + ), + /*replay_kind*/ None, + ); + let command = if cfg!(windows) { + "Start-Sleep -Seconds 30" + } else { + "sleep 30" + }; + app_server + .thread_shell_command(thread_id, command.to_string()) + .await?; + let turn_id = loop { + let event = time::timeout(Duration::from_secs(/*secs*/ 5), app_server.next_event()) + .await + .expect("app-server should emit a turn/start event") + .expect("app-server event stream should remain open"); + if let codex_app_server_client::AppServerEvent::ServerNotification(notification) = event + && let ServerNotification::TurnStarted(notification) = notification.as_ref() + && notification.thread_id == thread_id.to_string() + { + break notification.turn.id.clone(); + } + }; + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + started.session, + vec![test_turn(&turn_id, TurnStatus::InProgress, Vec::new())], + ), + ); + app.chat_widget.handle_server_notification( + turn_started_notification(thread_id, &turn_id), + /*replay_kind*/ None, + ); + while app_event_rx.try_recv().is_ok() {} + while op_rx.try_recv().is_ok() {} + let control = app + .handle_event( + &mut tui, + &mut app_server, + AppEvent::RunningTaskExit { + action: RunningTaskExitAction::Exit, + thread_id, + }, + ) + .await?; + + assert_matches!(control, AppRunControl::Continue); + assert!(op_rx.try_recv().is_err()); + let exit_event = app_event_rx.try_recv()?; + assert_matches!(exit_event, AppEvent::Exit(ExitMode::ShutdownAfterInterrupt)); + let AppRunControl::Exit(reason) = app + .handle_event(&mut tui, &mut app_server, exit_event) + .await? + else { + panic!("stop-and-exit must exit"); + }; + assert_matches!(reason, ExitReason::TurnInterrupted); + let output = app + .exit_info(reason) + .format_exit_messages(/*color_enabled*/ false) + .join("\n") + .replace(&thread_id.to_string(), "THREAD_ID"); + assert_snapshot!("interrupted_disconnect_exit", output); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_closes_running_side_thread_and_returns_to_parent() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let side_thread_id = prepare_running_local_daemon(&mut app)?; + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + let started = app_server.start_thread(&app.config).await?; + let parent_thread_id = started.session.thread_id; + app.primary_thread_id = Some(parent_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.thread_event_channels.insert( + parent_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + started.session, + started.turns, + ), + ); + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert_eq!(app.active_thread_id, Some(parent_thread_id)); + assert!(!app.side_threads.contains_key(&side_thread_id)); + assert!(op_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_hides_background_exit_for_running_background_side_thread() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let parent_thread_id = prepare_local_daemon_thread(&mut app)?; + let side_thread_id = ThreadId::new(); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.thread_event_channels.insert( + side_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + test_thread_session(side_thread_id, test_path_buf("/tmp/project")), + vec![test_turn("side-turn", TurnStatus::InProgress, Vec::new())], + ), + ); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r" + Task is still running + Choose what happens to the current task. + + › 1. Cancel task Stop the current task and stay in Codex + 2. Exit Stop the current task and exit Codex + + Press enter to confirm or esc to go back + "); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let event = app_event_rx.try_recv()?; + + assert_matches!( + event, + AppEvent::RunningTaskExit { + action: RunningTaskExitAction::Exit, + thread_id, + } if thread_id == side_thread_id + ); + assert!(op_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_hides_background_exit_with_queued_follow_up() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.chat_widget + .apply_external_edit("queued follow-up".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued follow-up".to_string()] + ); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("Run in background")); + assert!(op_rx.try_recv().is_err()); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn non_daemon_ctrl_c_keeps_interrupt_behavior() -> Result<()> { + for target in [ + AppServerTarget::Embedded, + AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500".to_string(), + auth_token: None, + }, + }, + ] { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.app_server_target = target; + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + assert!(app_event_rx.try_recv().is_err()); + } + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_with_draft_preserves_composer_cancellation() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.chat_widget.apply_external_edit("draft".to_string()); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(app.chat_widget.composer_is_empty()); + assert!(op_rx.try_recv().is_err()); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "draft" + ); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_during_paste_burst_does_not_show_exit_menu() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + prepare_running_local_daemon(&mut app)?; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!(app.chat_widget.composer_text_with_pending().is_empty()); + assert!(!app.chat_widget.composer_is_empty()); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert!(!app.chat_widget.composer_is_empty()); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn daemon_ctrl_c_during_mcp_startup_does_not_show_background_exit_menu() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let thread_id = prepare_local_daemon_thread(&mut app)?; + app.chat_widget + .set_mcp_startup_expected_servers(["slow".to_string()]); + app.chat_widget.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some(thread_id.to_string()), + name: "slow".to_string(), + status: McpServerStartupState::Starting, + error: None, + failure_reason: None, + }), + /*replay_kind*/ None, + ); + assert!(app.chat_widget.is_task_running_for_test()); + let (mut app_server, mut tui) = + prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?; + + open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await; + + assert!(app.chat_widget.no_modal_or_popup_active()); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + assert!(app_event_rx.try_recv().is_err()); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e1a673e336f2781d9ca839f4be325d8fc1193a1 --- /dev/null +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -0,0 +1,958 @@ +//! Request-level coverage for command-center session defaults and worktree creation. + +use super::*; +use crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID; +use crate::app::tests::session_lifecycle_requests::HistoryCapabilities; +use crate::app::tests::session_lifecycle_requests::recorded_params; +use crate::app::tests::session_lifecycle_requests::start_recording_app_server_with_history; +use crate::model_catalog::ModelCatalog; +use crate::test_support::PathBufExt; +use crate::tui::test_support::make_test_tui; +use codex_state::SqliteConfig; +use pretty_assertions::assert_eq; + +fn trust_launch_folder(app: &mut App) { + let projects = serde_json::json!({ + app.config.cwd.display().to_string(): {"trust_level": "trusted"}, + app.chat_widget.config_ref().cwd.display().to_string(): {"trust_level": "trusted"}, + }); + app.cli_kv_overrides.push(( + "projects".into(), + TomlValue::try_from(projects).expect("trust fixture"), + )); + app.config.active_project.trust_level = Some(codex_protocol::config_types::TrustLevel::Trusted); +} + +#[tokio::test] +async fn command_center_new_reads_server_defaults_for_actual_destination() -> Result<()> { + let mut tui = make_test_tui()?; + for (mode, explicit_cwd, launch_override, expected_cwd, expected_model) in [ + ("local", false, false, "launch", "server-model"), + ("local", true, false, "destination", "destination-model"), + ("local-cli-provider", false, false, "launch", "server-model"), + ("local-cli-model", false, false, "launch", "cli-model"), + ( + "local-default-provider", + false, + false, + "launch", + "server-model", + ), + ("remote", true, true, "destination", "destination-model"), + ("remote", true, false, "destination", "destination-model"), + ("remote", false, true, "launch", "server-model"), + ("remote", false, false, ".", "server-model"), + ("remote-null-fast", false, false, ".", ""), + ] { + let client_home = tempdir()?; + let server_home = tempdir()?; + let launch = tempdir()?; + let destination = tempdir()?; + std::fs::write( + client_home.path().join("config.toml"), + format!( + "model = \"{}\"\nmodel_reasoning_effort = \"low\"\n{}", + if mode == "remote-null-fast" { + "gpt-5.2" + } else { + "client-model" + }, + if mode == "local-default-provider" { + "model_provider = \"ollama\"\n" + } else { + "" + } + ), + )?; + std::fs::write( + server_home.path().join("config.toml"), + format!( + "{}model_reasoning_effort = \"high\"\n{}", + if mode == "remote-null-fast" { + "" + } else { + "model = \"server-model\"\n" + }, + if mode == "local" || mode == "local-cli-provider" || mode == "local-cli-model" { + "model_provider = \"ollama\"\n" + } else { + "" + } + ), + )?; + std::fs::create_dir(destination.path().join(".codex"))?; + std::fs::write( + destination.path().join(".codex/config.toml"), + "model = \"destination-model\"\nservice_tier = \"flex\"\n", + )?; + for home in [client_home.path(), server_home.path()] { + crate::legacy_core::config::set_project_trust_level( + home, + destination.path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } + let mut app = make_test_app_with_channels().await.0; + trust_launch_folder(&mut app); + if mode == "local" && explicit_cwd { + app.harness_overrides.model_provider = Some("openai".into()); + } + if mode == "local-cli-provider" { + app.cli_kv_overrides + .push(("model_provider".into(), TomlValue::String("openai".into()))); + } + if mode == "local-cli-model" { + app.harness_overrides.model = Some("cli-model".into()); + } + app.chat_widget.set_service_tier(Some("priority".into())); + app.harness_overrides.cwd = Some(launch.path().to_path_buf()); + app.config = ConfigBuilder::default() + .codex_home(client_home.path().to_path_buf()) + .loader_overrides(app.loader_overrides.clone()) + .cli_overrides(app.cli_kv_overrides.clone()) + .harness_overrides(app.harness_overrides.clone()) + .build() + .await?; + if mode == "remote-null-fast" { + app.config.features.enable(Feature::FastMode)?; + } + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + ThreadId::new(), + launch.path().to_path_buf(), + )); + trust_launch_folder(&mut app); + let mut server_config = app.config.clone(); + server_config.codex_home = server_home.path().to_path_buf().abs(); + server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs()); + let thread_mode = if mode.starts_with("remote") { + crate::app_server_session::ThreadParamsMode::Remote + } else { + crate::app_server_session::ThreadParamsMode::Embedded + }; + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &server_config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + thread_mode, + LoaderOverrides { + user_config_path: Some(server_home.path().join("config.toml").abs()), + ..LoaderOverrides::default() + }, + ) + .await?; + if launch_override { + server = server.with_remote_cwd_override(Some(launch.path().to_path_buf())); + } + if mode.starts_with("remote") && !launch_override { + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:1".into(), + auth_token: None, + }, + }; + let mut restored = app.config.clone(); + restored + .permissions + .set_permission_profile(codex_protocol::models::PermissionProfile::Disabled)?; + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(&restored), + ); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( + codex_app_server_protocol::AskForApproval::Never, + )); + } + let bootstrap = server.bootstrap(&app.config).await?; + let expected_model = if mode == "remote-null-fast" { + let default_model = bootstrap + .available_models + .iter() + .find(|model| model.is_default) + .or_else(|| bootstrap.available_models.first()) + .expect("server catalog model") + .model + .clone(); + app.model_catalog = Arc::new(ModelCatalog::new(bootstrap.available_models)); + default_model + } else { + expected_model.to_string() + }; + app.new_agents_overview_session( + &mut tui, + &mut server, + explicit_cwd.then(|| destination.path().to_path_buf().abs()), + ) + .await?; + let cwd = match expected_cwd { + "launch" => launch.path().display().to_string(), + "destination" => destination.path().display().to_string(), + "." => ".".to_string(), + _ => unreachable!(), + }; + assert_eq!( + recorded_params(&requests, "config/read"), + vec![serde_json::json!({"cwd": cwd})], + "{mode} {expected_cwd}" + ); + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1, "{mode} {expected_cwd}"); + assert_eq!( + ( + &starts[0]["cwd"], + &starts[0]["model"], + &starts[0]["modelProvider"], + &starts[0]["config"]["model_reasoning_effort"], + &starts[0]["serviceTier"], + ), + ( + &if mode.starts_with("remote") && !explicit_cwd && !launch_override { + serde_json::Value::Null + } else { + serde_json::json!(cwd) + }, + &serde_json::json!(expected_model), + &if mode.starts_with("remote") { + serde_json::Value::Null + } else if mode == "local" && !explicit_cwd { + serde_json::json!("ollama") + } else { + serde_json::json!("openai") + }, + &serde_json::json!("high"), + &serde_json::json!(if mode == "local" && explicit_cwd { + "flex" + } else { + "priority" + }), + ), + "{mode} {expected_cwd}" + ); + if mode.starts_with("remote") && !launch_override { + assert_eq!(app.config.cwd, launch.path().to_path_buf().abs()); + assert_ne!(starts[0]["sandbox"], "danger-full-access"); + assert_ne!(starts[0]["approvalPolicy"], "never"); + } + assert_eq!(recorded_params(&requests, "turn/start").len(), 0); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> Result<()> { + let mut tui = make_test_tui()?; + for (choice, expected_model, expected_effort) in [ + ("saved", "server-model", "high"), + ("cli_effort", "server-model", "low"), + ("profile_model", "profile-model", "high"), + ("profile_managed", "profile-model", "low"), + ("managed", "managed-model", "medium"), + ] { + let client_home = tempdir()?; + let server_home = tempdir()?; + std::fs::write( + client_home.path().join("config.toml"), + "model = \"client-model\"\nmodel_reasoning_effort = \"low\"\n", + )?; + std::fs::write( + server_home.path().join("config.toml"), + "model = \"server-model\"\nmodel_reasoning_effort = \"high\"\n", + )?; + if choice == "managed" || choice == "profile_managed" || choice.starts_with("cli_") { + std::fs::write( + server_home.path().join("requirements.toml"), + "[models.new_thread]\nmodel = \"managed-model\"\nmodel_reasoning_effort = \"medium\"\n", + )?; + } + let mut app = make_test_app_with_channels().await.0; + trust_launch_folder(&mut app); + match choice { + "cli_effort" => app.cli_kv_overrides.push(( + "model_reasoning_effort".into(), + TomlValue::String("low".into()), + )), + "profile_model" | "profile_managed" => { + let path = client_home.path().join("work.config.toml"); + std::fs::write( + &path, + if choice == "profile_managed" { + "model = \"profile-model\"\nmodel_reasoning_effort = \"low\"\n" + } else { + "model = \"profile-model\"\n" + }, + )?; + app.loader_overrides.user_config_path = Some(path.abs()); + app.loader_overrides.user_config_profile = Some("work".parse()?); + } + _ => {} + } + app.config = ConfigBuilder::default() + .codex_home(client_home.path().to_path_buf()) + .loader_overrides(app.loader_overrides.clone()) + .cli_overrides(app.cli_kv_overrides.clone()) + .harness_overrides(app.harness_overrides.clone()) + .build() + .await?; + trust_launch_folder(&mut app); + let mut server_config = app.config.clone(); + server_config.codex_home = server_home.path().to_path_buf().abs(); + server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs()); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &server_config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + LoaderOverrides { + user_config_path: Some(server_home.path().join("config.toml").abs()), + system_requirements_path: Some(server_home.path().join("requirements.toml")), + ..LoaderOverrides::default() + }, + ) + .await?; + server.bootstrap(&app.config).await?; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1, "{choice}"); + assert_eq!( + ( + &starts[0]["model"], + &starts[0]["config"]["model_reasoning_effort"] + ), + ( + &serde_json::json!(expected_model), + &serde_json::json!(expected_effort) + ), + "{choice}" + ); + assert_eq!( + recorded_params(&requests, "turn/start").len(), + 0, + "{choice}" + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> Result<()> { + let mut tui = make_test_tui()?; + for capability in [ + HistoryCapabilities::ConfigReadFails, + HistoryCapabilities::ThreadStartFails, + HistoryCapabilities::ConfigReadUnsupported(-32600), + HistoryCapabilities::ConfigReadUnsupported(-32601), + ] { + let (mut app, mut events, _) = make_test_app_with_channels().await; + trust_launch_folder(&mut app); + app.harness_overrides.model = Some("local-model".into()); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + capability, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let source_colors = !app.config.tui_status_line_use_colors; + app.local_settings.tui.status_line_use_colors = source_colors; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let failed = matches!( + capability, + HistoryCapabilities::ConfigReadFails | HistoryCapabilities::ThreadStartFails + ); + assert_eq!(recorded_params(&requests, "config/read").len(), 1); + assert_eq!( + recorded_params(&requests, "thread/start").len(), + usize::from(capability != HistoryCapabilities::ConfigReadFails) + ); + assert_eq!(recorded_params(&requests, "turn/start").len(), 0); + if failed { + assert_eq!(app.local_settings.tui.status_line_use_colors, source_colors); + assert!(app.agents_overview.dispatched_requests.is_empty()); + assert!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + ); + let error = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .find(|message| message.contains("Failed to")) + .expect("visible read error"); + if capability == HistoryCapabilities::ConfigReadFails { + insta::assert_snapshot!(error, @"■ Failed to load new session settings: config/read failed in TUI"); + } else { + insta::assert_snapshot!( + "command_center_session_start_error", + crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 80, + ) + ); + } + } else { + assert_eq!( + recorded_params(&requests, "thread/start")[0]["model"], + "local-model" + ); + } + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn command_center_new_preserves_permissions_across_sessions() -> Result<()> { + let mut app = make_test_app_with_channels().await.0; + trust_launch_folder(&mut app); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + assert!( + app.apply_permission_profile_selection(PermissionProfileSelection { + profile_id: ":read-only".into(), + approval_policy: Some(AskForApproval::UnlessTrusted), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".into(), + }) + .await + ); + let mut tui = make_test_tui()?; + for _ in 0..2 { + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_eq!( + ( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + app.chat_widget + .config_ref() + .permissions + .approval_policy + .value(), + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + ), + ( + &codex_protocol::models::PermissionProfile::read_only(), + codex_protocol::protocol::AskForApproval::UnlessTrusted, + Some(ActivePermissionProfile::new(":read-only")), + ), + ); + } + assert_eq!(recorded_params(&requests, "thread/start").len(), 2); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn command_center_new_preserves_only_selected_server_profiles() -> Result<()> { + let mut app = make_test_app_with_channels().await.0; + trust_launch_folder(&mut app); + let home = tempdir()?; + std::fs::write( + home.path().join("config.toml"), + "default_permissions = \":workspace\"\n[permissions.server-only]\nextends = \":read-only\"\n", + )?; + let server_config = ConfigBuilder::default() + .codex_home(home.path().into()) + .build() + .await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:8765")?, + }; + let client = crate::start_embedded_app_server( + codex_arg0::Arg0DispatchPaths::default(), + server_config, + Vec::new(), + LoaderOverrides::default(), + /*strict_config*/ false, + CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + /*state_db*/ None, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ) + .await?; + let mut server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess(client), + crate::app_server_session::ThreadParamsMode::Remote, + ); + let selection = PermissionProfileSelection { + profile_id: "server-only".into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "server-only".into(), + }; + let started = server + .start_thread_with_session_start_source( + &app.local_settings, + &app.config, + /*session_start_source*/ None, + /*remote_cwd_override*/ None, + Some(&selection), + ) + .await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()), + ); + let mut tui = make_test_tui()?; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + None + ); + let thread_id = app.chat_widget.thread_id().expect("new session"); + let other = server.start_thread(&app.config).await?.session.thread_id; + // Exercise the persisted-history attachment path rather than blank-task reuse. + app.agents_overview.blank_sessions.remove(&thread_id); + for id in [thread_id, other] { + server.thread_inject_items(id, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "saved history"}] + }))?]).await?; + } + app.select_permission_profile(&mut server, selection.clone()) + .await; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + insta::assert_snapshot!( + "command_center_pending_server_permissions", + crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + let settings = next_thread_settings_updated(&mut server, thread_id).await; + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadSettingsUpdated(settings), + ) + .await?; + for target in [other, thread_id] { + app.select_agents_overview_thread(&mut tui, &mut server, target) + .await?; + assert_eq!( + app.chat_widget.thread_id(), + Some(target), + "{}", + crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 120 + ) + ); + } + for attempt in 0..3 { + let previous_thread_id = app.chat_widget.thread_id(); + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + assert_ne!(app.chat_widget.thread_id(), previous_thread_id); + let config = app.chat_widget.config_ref(); + assert_eq!( + ( + config.permissions.active_permission_profile(), + config.permissions.permission_profile(), + config.permissions.approval_policy.value(), + ), + ( + Some(ActivePermissionProfile { + id: "server-only".into(), + extends: Some(":read-only".into()), + }), + &PermissionProfile::read_only(), + codex_protocol::protocol::AskForApproval::OnRequest, + ) + ); + if attempt == 1 { + app.agents_overview + .selected_permission_profiles + .remove(&app.chat_widget.thread_id().unwrap()); + app.select_permission_profile(&mut server, selection.clone()) + .await; + } + } + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + trust_launch_folder(&mut app); + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:8765")?, + }; + app.cli_kv_overrides.push(( + "approvals_reviewer".into(), + TomlValue::String("auto_review".into()), + )); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + LoaderOverrides::default(), + ) + .await?; + let mut tui = make_test_tui()?; + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let first = app.chat_widget.thread_id().unwrap(); + app.chat_widget.insert_str("Keep this unsent draft"); + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let other = app.chat_widget.thread_id().unwrap(); + app.select_agents_overview_thread(&mut tui, &mut server, first) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(first)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Keep this unsent draft" + ); + insta::assert_snapshot!( + crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80) + .lines().next().unwrap(), @"› Keep this unsent draft"); + // Seed persisted history without sending the user's draft. Subsequent navigation + // must exercise thread/resume, including its restoration of permission settings. + for id in [first, other] { + server.thread_inject_items(id, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "saved history"}] + }))?]).await?; + app.agents_overview.blank_sessions.remove(&id); + } + for profile_id in [":read-only", ":workspace"] { + app.select_agents_overview_thread(&mut tui, &mut server, first) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(first)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Keep this unsent draft" + ); + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + profile_id: profile_id.into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: profile_id.into(), + }, + ) + .await; + while let Ok(event) = events.try_recv() { + if matches!(event, AppEvent::CodexOp(_)) { + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + } + // Test both immediate creation and creation after A -> B -> A. + for switch in [false, true] { + if switch { + for target in [other, first] { + app.select_agents_overview_thread(&mut tui, &mut server, target) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(target)); + } + } + app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) + .await?; + let starts = recorded_params(&requests, "thread/start"); + let params = starts.last().unwrap(); + assert_eq!( + (¶ms["permissions"], ¶ms["approvalsReviewer"]), + (&serde_json::json!(profile_id), &serde_json::json!("user")) + ); + assert_eq!( + ( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + app.chat_widget.config_ref().approvals_reviewer + ), + ( + Some(ActivePermissionProfile::new(profile_id)), + ApprovalsReviewer::User + ), + ); + } + } + app.select_agents_overview_thread(&mut tui, &mut server, first) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Keep this unsent draft" + ); + assert!(recorded_params(&requests, "turn/start").is_empty()); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn command_center_new_checkout_and_worktree_preserve_source_and_default_branch() -> Result<()> +{ + use codex_worktree::CreateWorktree; + use codex_worktree::WorktreeManager; + use codex_worktree::WorktreeSettings; + for (remote_only, stale_local) in [(false, false), (true, false), (false, true)] { + let directory = tempdir()?; + let source = directory.path().join("project"); + std::fs::create_dir_all(source.join("subdir"))?; + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(["-c", "commit.gpgSign=false"]) + .args(args) + .current_dir(&source) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@example.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@example.com") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-b", "main"]); + std::fs::write(source.join("subdir/file"), "default branch")?; + git(&["add", "."]); + git(&["commit", "-m", "default"]); + if remote_only { + git(&["update-ref", "refs/remotes/origin/release/default", "HEAD"]); + git(&[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/release/default", + ]); + } + git(&["checkout", "-b", "feature"]); + std::fs::write(source.join("subdir/file"), "feature commit")?; + git(&["commit", "-am", "feature"]); + if remote_only { + git(&["branch", "-D", "main"]); + } + if stale_local { + git(&["update-ref", "refs/remotes/origin/main", "main"]); + git(&[ + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/main", + ]); + git(&["branch", "-f", "main", "feature"]); + } + let home = directory.path().join("home"); + std::fs::create_dir(&home)?; + std::fs::write( + home.join("config.toml"), + format!( + "approvals_reviewer = \"auto_review\"\nsandbox_mode = \"workspace-write\"\napproval_policy = \"on-request\"\nwindows.sandbox = \"unelevated\"\n[projects.{:?}]\ntrust_level = \"trusted\"\n", + source.canonicalize()?.display().to_string(), + ), + )?; + let manager = + WorktreeManager::new(WorktreeSettings::for_cli(&home, /*desktop*/ None).unwrap()); + let selected = manager + .create(&CreateWorktree { + source_cwd: source.join("subdir"), + base: Some("feature".into()), + }) + .unwrap(); + manager + .bind_thread(&selected.root, "existing-owner") + .unwrap(); + std::fs::write(selected.cwd.join("file"), "dirty edit")?; + std::fs::write(selected.cwd.join("untracked"), "untracked")?; + for receiver_closed in [true, false] { + let unclaimed = manager + .create(&CreateWorktree { + source_cwd: source.join("subdir"), + base: Some("feature".into()), + }) + .unwrap(); + let root = unclaimed.root.clone(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let receiver = (!receiver_closed).then_some(rx); + AppEventSender::new(tx).send(AppEvent::AgentsOverviewWorktreeCreated(Ok( + PendingWorktree { + manager: manager.clone(), + checkout: Some(unclaimed), + }, + ))); + drop(receiver); + assert!(!root.exists()); + assert_eq!(manager.list(&source).unwrap().len(), 1); + } + let (mut app, mut events, _) = make_test_app_with_channels().await; + app.config.codex_home = home.clone().abs(); + app.config.cwd = selected.cwd.clone().abs(); + app.chat_widget.windows_sandbox_local_server = cfg!(target_os = "windows"); + app.harness_overrides.cwd = Some(selected.cwd.clone()); + app.cli_kv_overrides + .push(("features.worktrees".into(), TomlValue::Boolean(true))); + app.config.features.enable(Feature::Worktrees)?; + trust_launch_folder(&mut app); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let mut tui = make_test_tui()?; + let new_session = app.new_agents_overview_session( + &mut tui, + &mut server, + Some(selected.cwd.clone().abs()), + ); + let size = std::mem::size_of_val(&new_session); + assert!( + size < 64 * 1024, + "new-session wrapper future is {size} bytes" + ); + new_session.await?; + let first = app.chat_widget.thread_id().expect("new checkout session"); + assert!(!crate::session_resume::cwds_differ( + app.chat_widget.config_ref().cwd.as_path(), + &selected.cwd + )); + assert_eq!( + manager.owner(&selected.root).unwrap(), + Some("existing-owner".into()) + ); + // Exercise the menu's ordered events, not the separate profile-selection API. + app.chat_widget + .set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); + Box::pin(app.handle_event(&mut tui, &mut server, AppEvent::OpenPermissionsPopup)).await?; + app.chat_widget.handle_key_event(KeyCode::Up.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + while let Ok(event) = events.try_recv() { + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + assert_eq!( + app.chat_widget.config_ref().approvals_reviewer, + ApprovalsReviewer::User + ); + app.new_agents_overview_worktree(&mut tui, &mut server, Some(selected.cwd.clone().abs())) + .await; + assert!(app.pending_managed_worktree_creation); + let result = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Some(AppEvent::AgentsOverviewWorktreeCreated(result)) = events.recv().await { + break result; + } + } + }) + .await? + .map_err(|message| color_eyre::eyre::eyre!(message))?; + let checkout = result.checkout.as_ref().unwrap().clone(); + assert_eq!( + std::fs::read_to_string(checkout.cwd.join("file"))?, + "default branch" + ); + assert!(!checkout.cwd.join("untracked").exists()); + assert_eq!(checkout.cwd, checkout.root.join("subdir")); + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::AgentsOverviewWorktreeCreated(Ok(result)), + )) + .await?; + let second = app.chat_widget.thread_id().expect("new worktree session"); + assert_ne!(first, second); + assert_eq!( + recorded_params(&requests, "thread/start").last().unwrap()["approvalsReviewer"], + serde_json::json!("user") + ); + assert_eq!( + app.chat_widget.config_ref().approvals_reviewer, + ApprovalsReviewer::User + ); + assert_eq!( + manager.owner(&checkout.root).unwrap(), + Some(second.to_string()) + ); + assert_eq!(recorded_params(&requests, "turn/start").len(), 0); + assert_eq!( + std::fs::read_to_string(selected.cwd.join("file"))?, + "dirty edit" + ); + server.shutdown().await?; + proxy.await??; + if !remote_only && !stale_local { + let unused = manager + .create(&CreateWorktree { + source_cwd: source.join("subdir"), + base: Some("main".into()), + }) + .unwrap(); + let (mut failed_server, _, failed_proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::ThreadStartFails, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.start_agents_overview_session( + &mut tui, + &mut failed_server, + Some(unused.cwd.clone().abs()), + Some((manager.clone(), unused.clone())), + ) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(second)); + assert_eq!(manager.owner(&unused.root).unwrap(), None); + assert!(unused.root.exists()); + let rendered = crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 500, + ); + insta::assert_snapshot!( + "command_center_retained_worktree_error", + rendered.replace(&unused.root.display().to_string(), "") + ); + failed_server.shutdown().await?; + failed_proxy.await??; + } + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/buffered_replay.rs b/codex-rs/tui/src/app/tests/buffered_replay.rs new file mode 100644 index 0000000000000000000000000000000000000000..708efcacb6afdcb3f773965a68a56cecf6cbba55 --- /dev/null +++ b/codex-rs/tui/src/app/tests/buffered_replay.rs @@ -0,0 +1,805 @@ +//! Real buffered notifications must use finalized rendering without losing live stream tails. + +use super::*; +use crate::chatwidget::tests::helpers::normalize_snapshot_paths; +use crate::chatwidget::tests::helpers::render_bottom_popup; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; +use pretty_assertions::assert_eq; + +fn delta(thread: &str, turn: &str, item: &str) -> ServerNotification { + ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { + thread_id: thread.into(), + turn_id: turn.into(), + item_id: item.into(), + delta: "Partial **answer**\n".into(), + }) +} + +fn completed(thread: &str) -> ServerNotification { + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread.into(), + turn_id: "turn".into(), + completed_at_ms: 0, + item: ThreadItem::AgentMessage { + id: "answer".into(), + text: "Final **answer**".into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + }) +} + +#[tokio::test] +async fn refreshed_active_reasoning_accepts_later_deltas_and_complete_summary() { + for (refresh, refreshed_item_is_partial, capacity, voice_handoff) in [ + (true, false, 1, false), + (true, true, 1, false), + (false, false, 8, false), + (false, false, 1, false), + (true, false, 1, true), + ] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, app.config.cwd.to_path_buf()); + let mut turn = test_turn("reasoning-turn", TurnStatus::InProgress, Vec::new()); + let channel = + ThreadEventChannel::new_with_session(capacity, session.clone(), vec![turn.clone()]); + { + let mut store = channel.store.lock().await; + store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: thread_id.to_string(), + turn_id: turn.id.clone(), + item: ThreadItem::Reasoning { + id: "reasoning".into(), + summary: Vec::new(), + content: Vec::new(), + }, + started_at_ms: 0, + })); + store.push_notification(ServerNotification::ReasoningSummaryTextDelta( + ReasoningSummaryTextDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: turn.id.clone(), + item_id: "reasoning".into(), + delta: "**First heading**\nOriginal analysis paragraph".into(), + summary_index: 0, + }, + )); + assert_eq!(store.buffer.len(), capacity.min(/*other*/ 2)); + if voice_handoff { + store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: thread_id.to_string(), + turn_id: turn.id.clone(), + item: ThreadItem::UserMessage { + id: "voice-handoff".into(), + client_id: None, + content: vec![AppServerUserInput::Text { + text: "spoken follow-up".into(), + text_elements: Vec::new(), + }], + }, + started_at_ms: 0, + })); + } + } + let mut snapshot = channel.store.lock().await.snapshot(); + app.thread_event_channels.insert(thread_id, channel); + if refreshed_item_is_partial { + turn.items.push(ThreadItem::Reasoning { + id: "reasoning".into(), + summary: vec!["**First heading**\nOriginal analysis paragraph".into()], + content: Vec::new(), + }); + } + if refresh { + app.apply_refreshed_snapshot_thread( + thread_id, + AppServerStartedThread { + session, + turns: vec![turn], + blocks_direct_input: false, + task_tools_available: false, + }, + &mut snapshot, + ) + .await; + assert!(snapshot.events.is_empty()); + } + assert!(snapshot.active_reasoning_item.is_some()); + app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); + app.chat_widget.handle_server_notification( + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: "reasoning-turn".into(), + item_id: "reasoning".into(), + delta: "\n**Running checks**\nFinal paragraph".into(), + summary_index: 0, + }), + /*replay_kind*/ None, + ); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Final paragraph")); + let completed = ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: "reasoning-turn".into(), + item: ThreadItem::Reasoning { + id: "reasoning".into(), + summary: vec![ + "**First heading**\nOriginal analysis paragraph\n**Running checks**\nFinal paragraph" + .into(), + ], + content: Vec::new(), + }, + completed_at_ms: 0, + }); + let store = &app.thread_event_channels[&thread_id].store; + store.lock().await.push_notification_ref(&completed); + assert!( + store + .lock() + .await + .snapshot() + .active_reasoning_item + .is_none() + ); + app.chat_widget + .handle_server_notification(completed, /*replay_kind*/ None); + app.chat_widget.handle_server_notification( + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: "reasoning-turn".into(), + item_id: "reasoning".into(), + delta: "\n**Stale heading**".into(), + summary_index: 0, + }), + /*replay_kind*/ None, + ); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Stale heading")); + let transcript = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) + if cell + .as_any() + .is::() => + { + Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::(); + insta::allow_duplicates! { + insta::assert_snapshot!(transcript, @" + • Original analysis paragraph + Running checks + Final paragraph + "); + } + } +} + +#[test] +fn buffered_replay_keeps_unfinished_and_unrelated_deltas_in_order() { + let completion = completed("thread"); + let unfinished = delta("thread", "turn", "unfinished"); + let other_turn = delta("thread", "other-turn", "answer"); + let other_thread = delta("other-thread", "turn", "answer"); + let later_delta = delta("thread", "turn", "answer"); + let mut store = ThreadEventStore::new(/*capacity*/ 16); + for event in [ + delta("thread", "turn", "answer"), + unfinished.clone(), + other_turn.clone(), + other_thread.clone(), + completion.clone(), + later_delta.clone(), + ] { + store.push_notification(event); + } + let mut events = store.snapshot().events; + replay_filter::omit_completed_agent_deltas(&mut events); + let actual = events + .into_iter() + .map(|event| match event { + ThreadBufferedEvent::Notification(notification) => *notification, + other => panic!("unexpected event: {other:?}"), + }) + .collect::>(); + assert_eq!( + serde_json::to_value(actual).unwrap(), + serde_json::to_value(vec![ + unfinished, + other_turn, + other_thread, + completion, + later_delta + ]) + .unwrap() + ); + assert_eq!(store.snapshot().events.len(), 6); +} + +#[test] +fn evicted_voice_delegation_marker_still_suppresses_private_replay() { + let mut store = ThreadEventStore::new(/*capacity*/ 2); + let voice_request = ThreadItem::UserMessage { + id: "user".into(), + client_id: None, + content: vec![codex_app_server_protocol::UserInput::Text { + text: "question".into(), + text_elements: Vec::new(), + }], + }; + store.push_notification(ServerNotification::ItemStarted( + codex_app_server_protocol::ItemStartedNotification { + thread_id: "thread".into(), + turn_id: "voice-turn".into(), + started_at_ms: 0, + item: voice_request.clone(), + }, + )); + store.push_notification(delta("thread", "voice-turn", "reasoning")); + let private = ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread".into(), + turn_id: "voice-turn".into(), + completed_at_ms: 0, + item: ThreadItem::AgentMessage { + id: "reasoning".into(), + text: "[ANALYSIS] private".into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + }); + store.push_notification(private.clone()); + assert!(store.snapshot().events.is_empty()); + store.push_notification(delta("thread", "voice-turn", "unfinished")); + assert!(store.snapshot().events.is_empty()); + assert_eq!(store.snapshot().delegated_turns, vec!["voice-turn"]); + for notification in [ + ServerNotification::ReasoningSummaryPartAdded( + codex_app_server_protocol::ReasoningSummaryPartAddedNotification { + thread_id: "thread".into(), + turn_id: "voice-turn".into(), + item_id: "reasoning".into(), + summary_index: 0, + }, + ), + ServerNotification::ReasoningSummaryTextDelta( + codex_app_server_protocol::ReasoningSummaryTextDeltaNotification { + thread_id: "thread".into(), + turn_id: "voice-turn".into(), + item_id: "reasoning".into(), + delta: "private summary".into(), + summary_index: 0, + }, + ), + ServerNotification::ReasoningTextDelta( + codex_app_server_protocol::ReasoningTextDeltaNotification { + thread_id: "thread".into(), + turn_id: "voice-turn".into(), + item_id: "reasoning".into(), + delta: "private raw reasoning".into(), + content_index: 0, + }, + ), + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread".into(), + turn_id: "voice-turn".into(), + completed_at_ms: 0, + item: ThreadItem::Reasoning { + id: "reasoning".into(), + summary: vec!["private summary".into()], + content: vec!["private raw reasoning".into()], + }, + }), + ] { + store.push_notification(notification); + assert!(store.snapshot().events.is_empty()); + } + + let mut resumed = ThreadEventStore::new(/*capacity*/ 1); + resumed.set_turns(vec![Turn { + id: "voice-turn".into(), + items: vec![voice_request], + items_view: codex_app_server_protocol::TurnItemsView::Summary, + status: TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }]); + resumed.push_notification(delta("thread", "voice-turn", "unfinished")); + let mut newer = resumed.turns[0].clone(); + newer.id = "newer-turn".into(); + resumed.set_turns(vec![resumed.turns[0].clone(), newer]); + assert!(resumed.snapshot().events.is_empty()); + resumed.push_notification(delta("thread", "newer-turn", "unfinished")); + assert!(resumed.snapshot().events.is_empty()); + let mut ordinary = ThreadEventStore::new(/*capacity*/ 2); + let ServerNotification::ItemCompleted(mut normal) = private else { + unreachable!() + }; + normal.turn_id = "normal-turn".into(); + ordinary.push_notification(ServerNotification::ItemCompleted(normal)); + assert_eq!(ordinary.snapshot().events.len(), 1); +} + +#[test] +fn saved_voice_turn_suppresses_reasoning_without_hiding_typed_reasoning() { + let reasoning = ThreadItem::Reasoning { + id: "reasoning".into(), + summary: vec!["private summary".into()], + content: vec!["private raw reasoning".into()], + }; + let voice_request = ThreadItem::UserMessage { + id: "voice-request".into(), + client_id: None, + content: vec![codex_app_server_protocol::UserInput::Text { + text: "question".into(), + text_elements: Vec::new(), + }], + }; + let turn = |id: &str, items| Turn { + id: id.into(), + items, + items_view: codex_app_server_protocol::TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }; + let mut store = ThreadEventStore::new(/*capacity*/ 4); + store.set_turns(vec![ + turn("voice", vec![voice_request.clone(), reasoning.clone()]), + turn("typed", vec![reasoning.clone()]), + ]); + assert_eq!( + store.snapshot().turns, + vec![ + turn("voice", vec![voice_request]), + turn("typed", vec![reasoning]) + ] + ); +} + +#[test] +fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() { + let commentary = |id: &str, text: &str| ThreadItem::AgentMessage { + id: id.into(), + text: text.into(), + phase: Some(codex_protocol::models::MessagePhase::Commentary), + questions: None, + memory_citation: None, + delivery: None, + }; + let marker = ThreadItem::UserMessage { + id: "voice".into(), + client_id: None, + content: vec![codex_app_server_protocol::UserInput::Text { + text: "question".into(), + text_elements: Vec::new(), + }], + }; + let typed = commentary("typed", "Earlier typed commentary"); + let private = commentary("private", "Later voice-private commentary"); + let mut store = ThreadEventStore::new(/*capacity*/ 8); + store.set_turns(vec![Turn { + id: "shared".into(), + items: vec![typed.clone(), marker.clone(), private.clone()], + items_view: codex_app_server_protocol::TurnItemsView::Full, + status: TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }]); + for item in [typed.clone(), marker.clone(), private] { + store.push_notification(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread".into(), + turn_id: "shared".into(), + completed_at_ms: 0, + item, + }, + )); + } + let snapshot = store.snapshot(); + assert_eq!(snapshot.turns[0].items, vec![typed.clone(), marker]); + let completed = snapshot + .events + .into_iter() + .filter_map(|event| match event { + ThreadBufferedEvent::Notification(notification) => match *notification { + ServerNotification::ItemCompleted(item) => Some(item.item), + _ => None, + }, + _ => None, + }) + .collect::>(); + assert_eq!(completed, vec![typed, snapshot.turns[0].items[1].clone()]); +} + +#[test] +fn snapshot_keeps_typed_item_completed_after_voice_handoff() { + let typed = ThreadItem::AgentMessage { + id: "typed".into(), + text: "Typed commentary completed late".into(), + phase: Some(codex_protocol::models::MessagePhase::Commentary), + questions: None, + memory_citation: None, + delivery: None, + }; + let private = ThreadItem::AgentMessage { + id: "private".into(), + text: "Voice-private commentary".into(), + phase: Some(codex_protocol::models::MessagePhase::Commentary), + questions: None, + memory_citation: None, + delivery: None, + }; + let marker = ThreadItem::UserMessage { + id: "voice".into(), + client_id: None, + content: vec![codex_app_server_protocol::UserInput::Text { + text: "question".into(), + text_elements: Vec::new(), + }], + }; + let mut store = ThreadEventStore::new(/*capacity*/ 8); + for item in [typed.clone(), marker, private.clone()] { + store.push_notification(ServerNotification::ItemStarted( + codex_app_server_protocol::ItemStartedNotification { + thread_id: "thread".into(), + turn_id: "shared".into(), + started_at_ms: 0, + item, + }, + )); + } + for item in [typed.clone(), private] { + store.push_notification(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread".into(), + turn_id: "shared".into(), + completed_at_ms: 0, + item, + }, + )); + } + let completed = store + .snapshot() + .events + .into_iter() + .filter_map(|event| match event { + ThreadBufferedEvent::Notification(notification) => match *notification { + ServerNotification::ItemCompleted(n) => Some(n.item), + _ => None, + }, + _ => None, + }) + .collect::>(); + assert_eq!(completed, vec![typed]); +} + +#[tokio::test] +async fn evicted_voice_marker_survives_widget_snapshot_for_late_reasoning() { + let thread_id = ThreadId::new(); + let mut store = ThreadEventStore::new(/*capacity*/ 1); + store.push_notification(ServerNotification::ItemStarted( + codex_app_server_protocol::ItemStartedNotification { + thread_id: thread_id.to_string(), + turn_id: "voice-turn".into(), + started_at_ms: 0, + item: ThreadItem::UserMessage { + id: "user".into(), + client_id: None, + content: vec![codex_app_server_protocol::UserInput::Text { + text: "question" + .into(), + text_elements: Vec::new(), + }], + }, + }, + )); + store.push_notification(delta(&thread_id.to_string(), "voice-turn", "private")); + let snapshot = store.snapshot(); + assert!(snapshot.events.is_empty()); + assert!(snapshot.turns.is_empty()); + assert_eq!(snapshot.delegated_turns, vec!["voice-turn"]); + + let mut app = make_test_app().await; + let (widget, _sender, mut events, _ops) = + crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(thread_id); + app.replace_chat_widget(widget); + app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); + app.chat_widget.handle_server_notification( + ServerNotification::ReasoningSummaryTextDelta( + codex_app_server_protocol::ReasoningSummaryTextDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: "voice-turn".into(), + item_id: "late-reasoning".into(), + delta: "private after switch".into(), + summary_index: 0, + }, + ), + /*replay_kind*/ None, + ); + app.chat_widget.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: "voice-turn".into(), + completed_at_ms: 0, + item: ThreadItem::Reasoning { + id: "late-reasoning".into(), + summary: vec!["private after switch".into()], + content: Vec::new(), + }, + }), + /*replay_kind*/ None, + ); + app.chat_widget.handle_server_notification( + delta(&thread_id.to_string(), "voice-turn", "private-commentary"), + /*replay_kind*/ None, + ); + app.chat_widget.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: "voice-turn".into(), + completed_at_ms: 0, + item: ThreadItem::AgentMessage { + id: "private-commentary".into(), + text: "[COMMENTARY] private after switch".into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + }), + /*replay_kind*/ None, + ); + while let Ok(event) = events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let rendered = cell + .transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::(); + assert!(!rendered.contains("private"), "{rendered}"); + } + } +} + +#[tokio::test] +async fn buffered_replay_renders_completed_text_without_streaming_again() { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut store = ThreadEventStore::new(/*capacity*/ 16); + store.push_notification(delta("thread", "turn", "answer")); + store.push_notification(completed("thread")); + app.replay_thread_snapshot(store.snapshot(), /*resume_restored_queue*/ false); + let mut lines = Vec::new(); + while let Ok(event) = events.try_recv() { + match event { + AppEvent::InsertHistoryCell(cell) => lines.extend(cell.display_lines(/*width*/ 80)), + AppEvent::StartCommitAnimation | AppEvent::ConsolidateAgentMessage { .. } => { + panic!("completed replay must not reconstruct a stream") + } + _ => {} + } + } + let text = lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + insta::assert_snapshot!(text.trim(), @" + • Final answer + "); +} + +#[test] +fn buffered_replay_preserves_text_before_interactive_requests() { + let mut store = ThreadEventStore::new(/*capacity*/ 16); + store.push_notification(delta("thread", "turn", "answer")); + store.push_request( + codex_app_server_protocol::ServerRequest::ToolRequestUserInput { + request_id: codex_app_server_protocol::RequestId::Integer(1), + params: codex_app_server_protocol::ToolRequestUserInputParams { + thread_id: "thread".into(), + turn_id: "turn".into(), + item_id: "tool".into(), + questions: Vec::new(), + is_blocking: true, + auto_resolution_ms: None, + }, + }, + ); + store.push_notification(completed("thread")); + let mut events = store.snapshot().events; + let before = format!("{events:?}"); + replay_filter::omit_completed_agent_deltas(&mut events); + assert_eq!(format!("{events:?}"), before); +} + +#[tokio::test] +async fn misalignment_buffered_replay_preserves_input_after_continuation() { + let (mut app, mut history, _) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + app.chat_widget.insert_str("keep queued"); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Tab)); + app.chat_widget + .restore_user_message_to_composer("keep draft".into()); + let saved_input = app.chat_widget.capture_thread_input_state(); + let error = AppServerTurnError { + misalignment: None, + message: "Chat stopped".into(), + codex_error_info: Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation), + additional_details: None, + }; + let failed_turn = Turn { + error: Some(error.clone()), + items: vec![ThreadItem::McpToolCall { + id: "tool-call".into(), + server: "sample".into(), + tool: "inspect".into(), + status: codex_app_server_protocol::McpToolCallStatus::InProgress, + arguments: serde_json::json!({}), + app_context: None, + mcp_app_resource_uri: None, + mcp_app_ui: None, + plugin_id: None, + read_only_hint: None, + result: None, + error: None, + duration_ms: None, + }], + ..test_turn("failed-turn", TurnStatus::Failed, Vec::new()) + }; + let continued_turn = test_turn("continued-turn", TurnStatus::Completed, Vec::new()); + // Primary resume also replays stored turns without going through the snapshot filter. + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + app.chat_widget.replay_thread_turns( + vec![failed_turn.clone(), continued_turn.clone()], + ReplayKind::ResumeInitialMessages, + ); + let rendered = std::iter::from_fn(|| history.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::(); + insta::assert_snapshot!("misalignment_replayed_failed_tool", rendered); + assert!(!app.chat_widget.has_misalignment_policy_violation()); + insta::assert_snapshot!( + "misalignment_replayed_input", + normalize_snapshot_paths(render_bottom_popup(&app.chat_widget, /*width*/ 80)) + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), "keep draft"); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["keep queued".to_string()] + ); + let events = [ + turn_started_notification(thread_id, "failed-turn"), + ServerNotification::Error(codex_app_server_protocol::ErrorNotification { + thread_id: thread_id.to_string(), + turn_id: "failed-turn".to_string(), + error, + will_retry: false, + }), + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: failed_turn.clone(), + }), + turn_started_notification(thread_id, "continued-turn"), + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: continued_turn, + }), + ]; + // Retain the whole stream, only the new turn's delta, or only a post-completion notice. + for (capacity, event_count) in [(16, 5), (1, 4), (1, 5)] { + let mut store = ThreadEventStore::new_with_session( + capacity, + test_thread_session(thread_id, app.config.cwd.to_path_buf()), + vec![failed_turn.clone()], + ); + store.input_state = saved_input.clone(); + for event in &events[..event_count] { + store.push_notification_ref(event); + } + if capacity == 1 { + store.push_notification(if event_count == 4 { + delta(&thread_id.to_string(), "continued-turn", "answer") + } else { + ServerNotification::Warning(WarningNotification { + message: "An unrelated notice".into(), + thread_id: Some(thread_id.to_string()), + }) + }); + assert_eq!(store.buffer.len(), 1); + } + app.replay_thread_snapshot(store.snapshot(), /*resume_restored_queue*/ false); + assert!(!app.chat_widget.has_misalignment_policy_violation()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "keep draft"); + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["keep queued".to_string()] + ); + } +} + +#[tokio::test] +async fn misalignment_replay_blocks_when_turn_start_was_evicted() { + let thread_id = ThreadId::new(); + let error = AppServerTurnError { + misalignment: None, + message: "Chat stopped".into(), + codex_error_info: Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation), + additional_details: None, + }; + let previous_turn = test_turn("old-turn", TurnStatus::Completed, Vec::new()); + let failed_turn = Turn { + error: Some(error.clone()), + ..test_turn("new-turn", TurnStatus::Failed, Vec::new()) + }; + for (stored_turn, notification) in [ + ( + previous_turn.clone(), + ServerNotification::Error(codex_app_server_protocol::ErrorNotification { + thread_id: thread_id.to_string(), + turn_id: "new-turn".into(), + error: error.clone(), + will_retry: false, + }), + ), + ( + previous_turn, + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: failed_turn.clone(), + }), + ), + // A settings-operation error has a submission ID, not evidence of a new turn. + ( + failed_turn, + ServerNotification::Error(codex_app_server_protocol::ErrorNotification { + thread_id: thread_id.to_string(), + turn_id: "settings-update".into(), + error: AppServerTurnError { + codex_error_info: Some(AppServerCodexErrorInfo::BadRequest), + ..error + }, + will_retry: false, + }), + ), + ] { + for saw_start in [false, true] { + let mut app = make_test_app().await; + let mut store = ThreadEventStore::new_with_session( + /*capacity*/ 1, + test_thread_session(thread_id, app.config.cwd.to_path_buf()), + vec![stored_turn.clone()], + ); + if saw_start { + store.push_notification(turn_started_notification(thread_id, "new-turn")); + } + store.push_notification_ref(¬ification); + assert_eq!(store.buffer.len(), 1); + app.replay_thread_snapshot(store.snapshot(), /*resume_restored_queue*/ false); + assert!(app.chat_widget.has_misalignment_policy_violation()); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Chat stopped")); + } + } +} diff --git a/codex-rs/tui/src/app/tests/connector_policy.rs b/codex-rs/tui/src/app/tests/connector_policy.rs new file mode 100644 index 0000000000000000000000000000000000000000..a806e90e22340e984d07e92303343dc955fb352e --- /dev/null +++ b/codex-rs/tui/src/app/tests/connector_policy.rs @@ -0,0 +1,310 @@ +use super::*; +use crate::app_event::ConnectorsSnapshot; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::AppListUpdatedNotification; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn installed_connector_mentions_ignore_stale_thread_workspace_and_account() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.chat_widget + .set_feature_enabled(Feature::Apps, /*enabled*/ true); + let thread_id = ThreadId::new(); + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + app.chat_widget + .handle_thread_session(test_thread_session(thread_id, cwd.clone())); + let previous_generation = app.chat_widget.connector_scope_generation(); + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + app.chat_widget.insert_str("$"); + + let generation = app.chat_widget.connector_scope_generation(); + let snapshot = ConnectorsSnapshot { + connectors: vec![serde_json::from_str( + r#"{"id":"scoped-app","name":"Scoped App","isAccessible":true}"#, + )?], + }; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + for (candidate_thread, candidate_cwd, candidate_generation, is_current) in [ + (Some(ThreadId::new()), cwd.clone(), generation, false), + ( + Some(thread_id), + cwd.join("other-workspace"), + generation, + false, + ), + (Some(thread_id), cwd.clone(), previous_generation, false), + (Some(thread_id), cwd.clone(), generation, true), + ] { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::InstalledConnectorMentionsLoaded { + thread_id: candidate_thread, + cwd: candidate_cwd, + generation: candidate_generation, + result: Ok(snapshot.clone()), + }, + ) + .await?; + assert_eq!( + render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Scoped App"), + is_current + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn queued_connector_fetches_are_bound_to_their_original_account() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + app.chat_widget + .handle_thread_session(test_thread_session(ThreadId::new(), cwd)); + while app_event_rx.try_recv().is_ok() {} + + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + let stale_fetch = app_event_rx.try_recv()?; + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + let current_fetch = app_event_rx.try_recv()?; + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let metrics = tokio::runtime::Handle::current().metrics(); + let initial_tasks = metrics.num_alive_tasks(); + + app.handle_event(&mut tui, &mut app_server, stale_fetch) + .await?; + assert_eq!(metrics.num_alive_tasks(), initial_tasks); + app.handle_event(&mut tui, &mut app_server, current_fetch) + .await?; + assert_eq!(metrics.num_alive_tasks(), initial_tasks + 1); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn app_list_notifications_revalidate_installed_mentions_and_the_current_directory() +-> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + app.chat_widget + .handle_thread_session(test_thread_session(thread_id, cwd.clone())); + app.chat_widget.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + let scoped_snapshot = ConnectorsSnapshot { + connectors: vec![serde_json::from_str( + r#"{"id":"scoped-app","name":"Scoped App","isAccessible":true}"#, + )?], + }; + app.chat_widget + .on_connectors_loaded(Ok(scoped_snapshot.clone()), /*is_final*/ true); + app.chat_widget.on_connector_mentions_loaded( + app.chat_widget.connector_scope_generation(), + Ok(scoped_snapshot.clone()), + ); + app.chat_widget.insert_str("$"); + while app_event_rx.try_recv().is_ok() {} + + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + for (id, name, should_refresh) in [ + ("scoped-app", "Scoped App", false), + ("old-workspace", "Old Workspace", true), + ("old-workspace", "Old Workspace", false), + ] { + let notification = ServerNotification::AppListUpdated(AppListUpdatedNotification { + data: vec![serde_json::from_value(serde_json::json!({ + "id": id, "name": name, "isAccessible": true + }))?], + }); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(notification)), + ) + .await; + + if should_refresh { + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchInstalledConnectorMentions { + force_refresh: true, + .. + }) + )); + } + assert_eq!( + matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchConnectorsList { + force_refetch: false, + .. + }) + ), + should_refresh + ); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 80); + assert!(popup.contains("Scoped App")); + assert!(!popup.contains("Old Workspace")); + } + + let generation = app.chat_widget.connector_scope_generation(); + app.chat_widget.on_connector_mentions_loaded( + generation, + Ok(ConnectorsSnapshot { + connectors: vec![serde_json::from_str( + r#"{"id":"old-workspace","name":"Old Workspace","isAccessible":true}"#, + )?], + }), + ); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Old Workspace")); + assert!(app_event_rx.try_recv().is_err()); + + // Revocations refresh mentions even if discovery already has this notification. + let revoked_notification = ServerNotification::AppListUpdated(AppListUpdatedNotification { + data: vec![serde_json::from_str( + r#"{"id":"scoped-app","name":"Scoped App","isAccessible":true}"#, + )?], + }); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(revoked_notification)), + ) + .await; + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchInstalledConnectorMentions { + force_refresh: true, + generation: requested_generation, + }) if requested_generation == generation + )); + assert!(app_event_rx.try_recv().is_err()); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Old Workspace")); + app.chat_widget + .on_connector_mentions_loaded(generation, Ok(scoped_snapshot.clone())); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Old Workspace")); + + let ready_app = r#"{"id":"newly-ready","name":"Newly Ready","isAccessible":true}"#; + let ready_notification = ServerNotification::AppListUpdated(AppListUpdatedNotification { + data: vec![serde_json::from_str(ready_app)?], + }); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ready_notification.clone())), + ) + .await; + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchInstalledConnectorMentions { + force_refresh: true, + .. + }) + )); + assert!(app_event_rx.try_recv().is_err()); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Newly Ready")); + + let mut tui = crate::tui::test_support::make_test_tui()?; + for should_retry in [true, false, true, false] { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ConnectorsLoaded { + thread_id: Some(thread_id), + cwd: cwd.clone(), + generation, + result: Err("app directory failed".to_string()), + is_final: true, + }, + ) + .await?; + assert_eq!( + matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchConnectorsList { + force_refetch: false, + .. + }) + ), + should_retry + ); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Newly Ready")); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ready_notification.clone())), + ) + .await; + assert_eq!(app_event_rx.try_recv().is_ok(), !should_retry); + if !should_retry { + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ready_notification.clone())), + ) + .await; + assert!(app_event_rx.try_recv().is_err()); + } + } + + for (snapshot, should_revalidate) in [ + (scoped_snapshot, true), + ( + ConnectorsSnapshot { + connectors: vec![serde_json::from_str(ready_app)?], + }, + false, + ), + ] { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ConnectorsLoaded { + thread_id: Some(thread_id), + cwd: cwd.clone(), + generation, + result: Ok(snapshot), + is_final: true, + }, + ) + .await?; + assert_eq!( + matches!( + app_event_rx.try_recv(), + Ok(AppEvent::FetchConnectorsList { + force_refetch: false, + .. + }) + ), + should_revalidate + ); + if should_revalidate { + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ready_notification.clone())), + ) + .await; + assert!(app_event_rx.try_recv().is_err()); + } + } + app.chat_widget.add_connectors_output(); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Newly Ready")); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/daybreak_tests.rs b/codex-rs/tui/src/app/tests/daybreak_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..89ece3189d6384d18d51784bbe7b1ba5ead7ac2a --- /dev/null +++ b/codex-rs/tui/src/app/tests/daybreak_tests.rs @@ -0,0 +1,146 @@ +use super::*; +use crate::daybreak::Notice; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::ErrorNotification; +use codex_login::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test] +async fn cyber_refusal_reads_eligibility_without_changing_the_model() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let backend = wiremock::MockServer::start().await; + app.config.chatgpt_base_url = backend.uri(); + app.config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + write_chatgpt_auth( + &app.config.codex_home, + ChatGptAuthFixture::new("test-token") + .account_id("account") + .chatgpt_user_id("user"), + AuthCredentialsStoreMode::File, + ) + .expect("write synthetic auth"); + let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let thread_id = ThreadId::new(); + let mut session = test_thread_session(thread_id, app.config.cwd.to_path_buf()); + session.model_provider_id = "openai".to_string(); + app.enqueue_primary_thread_session(session, Vec::new()) + .await?; + for (status, enrolled, model, replace_account, expected) in [ + (200, true, "gpt-5.6-sol", false, Notice::Limited), + (200, false, "gpt-5.6-sol", false, Notice::Apply), + (503, false, "gpt-5.6-sol", false, Notice::Limited), + (200, true, "gpt-6-astra", false, Notice::Astra), + (200, false, "gpt-6-astra", false, Notice::Astra), + (503, false, "gpt-6-astra", false, Notice::Astra), + (200, true, "gpt-6-astra-wm", false, Notice::Astra), + (200, true, "unmapped-model", false, Notice::Limited), + (200, false, "gpt-5.6-sol", true, Notice::Limited), + ] { + backend.reset().await; + let codex_home = app.config.codex_home.clone(); + let response = wiremock::ResponseTemplate::new(status) + .set_delay(if model == "gpt-5.6-sol" && enrolled { Duration::from_secs(1) } else { Duration::ZERO }) + .set_body_json(json!({"programs": [{"program":"cyber", "state": if enrolled { "active" } else { "inactive" }, "grants": if enrolled { json!([{"level":"tac2"}]) } else { json!([]) }}]})); + wiremock::Mock::given(wiremock::matchers::path("/accounts/verified_access")) + .and(wiremock::matchers::header( + "authorization", + "Bearer test-token", + )) + .respond_with(move |_: &wiremock::Request| { + if replace_account { + write_chatgpt_auth( + &codex_home, + ChatGptAuthFixture::new("test-token").account_id("other-account"), + AuthCredentialsStoreMode::File, + ) + .unwrap(); + } + response.clone() + }) + .expect(1) + .mount(&backend) + .await; + // Account discovery starts before a subsequent model switch to Sol. + app.chat_widget.set_model("gpt-6-astra"); + app.handle_app_server_event( + &server, + AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated( + AccountUpdatedNotification { + auth_mode: Some(AuthMode::Chatgpt), + plan_type: None, + }, + ))), + ) + .await; + if model != "gpt-5.6-sol" || !enrolled { + wait_for_notice(&app.chat_widget.cyber_policy_notice).await; + } + app.active_thread_id = Some(thread_id); + app.chat_widget.set_model(model); + while events.try_recv().is_ok() {} + // In the slow Sol case, discovery is still pending: do not block input on it. + tokio::time::timeout( + Duration::from_millis(250), + app.handle_app_server_event( + &server, + AppServerEvent::ServerNotification(Box::new(ServerNotification::Error( + ErrorNotification { + error: AppServerTurnError { + misalignment: None, + message: "server fallback".into(), + codex_error_info: Some(CodexErrorInfo::CyberPolicy), + additional_details: None, + }, + will_retry: false, + thread_id: thread_id.to_string(), + turn_id: "turn".into(), + }, + ))), + ), + ) + .await + .expect("refusal must not wait for eligibility"); + let event = app.active_thread_rx.as_mut().unwrap().recv().await.unwrap(); + app.handle_thread_event_now(event); + let cell = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .expect("cyber refusal cell"); + assert_eq!( + cell.display_lines(/*width*/ 80), + history_cell::new_cyber_policy_error_event(expected).display_lines(/*width*/ 80), + "{model}" + ); + assert_eq!( + wait_for_notice(&app.chat_widget.cyber_policy_notice) + .await + .for_model(model), + expected + ); + backend.verify().await; + assert_eq!(app.chat_widget.current_model(), model); + } + server.shutdown().await?; + Ok(()) +} + +async fn wait_for_notice(cache: &crate::daybreak::NoticeCache) -> Notice { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(notice) = cache.get() { + return *notice; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("eligibility should finish") +} diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..b1ed8d1e6b877ea6874c08a96e5851ccf6d6035c --- /dev/null +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -0,0 +1,383 @@ +//! Offline editing, uncertain submissions, and stale events never consume local work. + +use super::*; +use crate::app_server_session::ThreadParamsMode; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCRequest; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test] +async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()> { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let id = ThreadId::new(); + app.active_thread_id = Some(id); + app.primary_thread_id = Some(id); + app.chat_widget + .handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf())); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + app.app_server_target = AppServerTarget::Remote { + endpoint: endpoint.clone(), + }; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| { + assert_eq!(request.method, "turn/start"); + std::future::ready(None) + }) + .await + }); + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Remote, + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget + .restore_user_message_to_composer("only once".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let op = next_user_turn_op(&mut ops); + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + app.chat_widget + .restore_user_message_to_composer("queued before disconnect".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.chat_widget + .restore_user_message_to_composer("keep this draft".into()); + let overview = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(overview)); + app.chat_widget.show_selection_view(SelectionViewParams { + items: vec![SelectionItem { + name: "Stale server action".into(), + actions: vec![Box::new(|tx| tx.send(AppEvent::CodexOp(Op::Interrupt)))], + ..Default::default() + }], + ..Default::default() + }); + assert!(matches!( + app.handle_event(&mut tui, &mut session, AppEvent::CodexOp(op)) + .await?, + AppRunControl::Continue + )); + assert!(app.reconnect.offline); + assert!(!app.chat_widget.has_active_view()); + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('D'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE), + ] { + assert!(matches!( + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) + .await?, + AppRunControl::Continue + )); + } + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "keep this draft!" + ); + assert!(app.chat_widget.has_queued_follow_up_messages()); + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ false); + assert!(!app.chat_widget.maybe_send_next_queued_input()); + app.handle_event(&mut tui, &mut session, AppEvent::NewSession { name: None }) + .await?; + assert_eq!(app.current_displayed_thread_id(), Some(id)); + while let Ok(event) = events.try_recv() { + app.handle_event(&mut tui, &mut session, event).await?; + } + let history = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + loop { + if let Ok(text) = + tokio::fs::read_to_string(app.config.codex_home.join("history.jsonl")).await + && let Ok(entry) = + serde_json::from_str::(&text) + { + break entry; + } + tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await; + } + }) + .await?; + assert_eq!( + (history.session_id, history.text), + (id.to_string(), "only once".into()) + ); + assert_snapshot!( + "offline_draft", + format!( + "{}\n{}", + lines_to_single_string( + &app.transcript_cells + .last() + .unwrap() + .display_lines(/*width*/ 80) + ), + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + ); + let mut keymap = codex_config::types::TuiKeymap::default(); + keymap.chat.edit_queued_message = Some(codex_config::types::KeybindingsSpec::One( + codex_config::types::KeybindingSpec("ctrl-x up".into()), + )); + let runtime = + RuntimeKeymap::from_config(&keymap).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(keymap, &runtime); + app.keymap = runtime; + for key in [ + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ] { + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) + .await?; + } + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "queued before disconnect" + ); + assert!(!app.chat_widget.has_queued_follow_up_messages()); + let paste = "copied text ".repeat(/*n*/ 92); + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste(paste.clone())) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + format!("queued before disconnect{paste}") + ); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); + let draft = rendered + .split_once("› ") + .unwrap() + .1 + .split("\n\n") + .next() + .unwrap(); + assert_snapshot!("offline_expanded_paste", draft); + assert!(ops.try_recv().is_err()); + for character in ['c', 'C', 'd', 'D'] { + if character.eq_ignore_ascii_case(&'d') { + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)), + ) + .await?; + } + assert!(matches!( + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new( + KeyCode::Char(character), + KeyModifiers::CONTROL + )) + ) + .await?, + AppRunControl::Exit(ExitReason::UserRequested) + )); + } + session.shutdown().await?; + assert_eq!(server.await??, vec!["initialize", "turn/start"]); + Ok(()) +} + +#[tokio::test] +async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<()> { + let (mut app, mut events, _) = make_test_app_with_channels().await; + let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?; + assert!(!app.begin_reconnect(), "embedded behavior stays unchanged"); + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?, + }; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.chat_widget.handle_paste("task draft".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)), + ) + .await?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Paste("search query".into()), + ) + .await?; + assert!(app.begin_reconnect()); + for key in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), + ] { + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) + .await?; + } + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into())) + .await?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert!( + !std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( + event, + AppEvent::NewAgentsOverviewSession { .. } + | AppEvent::NewAgentsOverviewWorktree { .. } + | AppEvent::OpenResumePicker + )) + ); + assert_snapshot!( + "offline_command_center", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + session.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn remote_disconnect_retires_voice_before_reconnecting() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?, + }; + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + assert!(app.chat_widget.is_current_realtime_attempt( + thread_id, /*attempt_id*/ 0, /*input_generation*/ 0 + )); + + assert!(app.begin_reconnect()); + assert_eq!( + ( + app.reconnect.offline, + app.chat_widget.is_current_realtime_attempt( + thread_id, /*attempt_id*/ 0, /*input_generation*/ 0 + ), + ), + (true, false) + ); + assert!(app.begin_reconnect()); + assert!(!app.chat_widget.is_current_realtime_attempt( + thread_id, /*attempt_id*/ 0, /*input_generation*/ 0 + )); + Ok(()) +} + +// Common framing/bootstrap for both transports. Returning None deliberately loses a reply. +pub(super) async fn serve_reconnect_requests( + mut socket: tokio_tungstenite::WebSocketStream, + mut respond: impl FnMut(JSONRPCRequest) -> F, +) -> Result> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + F: std::future::Future>, +{ + let mut methods = Vec::new(); + while let Some(Ok(frame)) = socket.next().await { + let Message::Text(text) = frame else { continue }; + let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { + continue; + }; + methods.push(request.method.clone()); + let request_id = request.id.clone(); + let response = match request.method.as_str() { + "initialize" => Some(json!({"result": {"userAgent": "reconnect-test/2.0.0"}})), + "account/read" => { + Some(json!({"result": {"account": null, "requiresOpenaiAuth": false}})) + } + "model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), + "collaborationMode/list" => Some(json!({"result": {"data": []}})), + "configRequirements/read" => Some(json!({"result": {"requirements": null}})), + _ => respond(request).await, + }; + let Some(mut response) = response else { + socket.close(None).await?; + break; + }; + response["id"] = json!(request_id); + socket + .send(Message::Text(response.to_string().into())) + .await?; + } + Ok(methods) +} +#[tokio::test] +async fn lost_initial_thread_reply_keeps_startup_draft_offline() -> Result<()> { + for disconnect_first in [false, true] { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + app.app_server_target = AppServerTarget::Remote { + endpoint: endpoint.clone(), + }; + app.chat_widget + .restore_user_message_to_composer("startup draft".into()); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| { + assert_eq!(request.method, "thread/start"); + std::future::ready(None) + }) + .await + }); + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Remote, + ); + let result = crate::app_server_session::start_thread_with_request_handle( + session.request_handle(), + &app.local_settings, + app.config.clone(), + ThreadParamsMode::Remote, + /*remote_cwd_override*/ None, + session.thread_tool_transport(), + ) + .await; + let mut tui = crate::tui::test_support::make_test_tui()?; + if disconnect_first { + app.begin_reconnect(); + } + app.handle_event( + &mut tui, + &mut session, + AppEvent::StartupThreadStarted { result }, + ) + .await?; + assert!(app.reconnect.offline); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "startup draft" + ); + assert!( + !std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. })) + ); + session.shutdown().await?; + assert_eq!(server.await??, vec!["initialize", "thread/start"]); + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs new file mode 100644 index 0000000000000000000000000000000000000000..9b33abd62b417ce64b6dec98c44af0c31ed368e3 --- /dev/null +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -0,0 +1,637 @@ +use super::App; +use super::AppServerSession; +use super::Result; +use super::RuntimeKeymap; +use super::TuiEvent; +use super::make_test_app; +use super::start_config_write_test_app_server; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::chatwidget::tests::helpers::render_bottom_popup; +use crate::keymap::KeymapContext; +use crate::test_support::test_path_display; +use crate::tui::Tui; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +async fn chord_app() -> Result<(App, Tui, AppServerSession)> { + let mut app = make_test_app().await; + let mut config = TuiKeymap::default(); + config.global.open_transcript = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x ctrl-t".to_string(), + ))); + config.chat.interrupt_turn = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x ctrl-u".to_string(), + ))); + config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-x j".to_string()))); + config.list.accept = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x enter".to_string(), + ))); + let runtime = + RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; + + let app_server = start_config_write_test_app_server(&app).await?; + let tui = crate::tui::test_support::make_test_tui()?; + Ok((app, tui, app_server)) +} + +async fn press( + app: &mut App, + tui: &mut Tui, + app_server: &mut AppServerSession, + key: KeyEvent, +) -> Result<()> { + app.handle_tui_event(tui, app_server, TuiEvent::Key(key)) + .await?; + Ok(()) +} + +fn ctrl(ch: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL) +} + +#[tokio::test] +async fn vim_buffer_jumps_route_default_chords_in_normal_and_operator_contexts() -> Result<()> { + for (input, command, expected) in [ + ("one\ntwo\nthree", "gg", "!one\ntwo\nthree"), + ("one\ntwo\nthree", "dgg", "!"), + ("ag bg", "0fg", "a!g bg"), + ("ag bg", "0dfg", "! bg"), + ] { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str(input); + + for (index, key) in command.chars().enumerate() { + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char(key).into(), + ) + .await?; + if key == 'g' && index + 1 < command.len() { + assert!(app.key_chord_matcher.is_pending()); + } + } + + assert!(!app.key_chord_matcher.is_pending()); + app.chat_widget.insert_str("!"); + assert_eq!(app.chat_widget.composer_text_with_pending(), expected); + } + Ok(()) +} + +#[tokio::test] +async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + assert!(app.key_chord_matcher.is_pending()); + assert!(app.overlay.is_none()); + insta::assert_snapshot!( + render_bottom_popup(&app.chat_widget, /*width*/ 80) + .replace(&test_path_display("/tmp/project"), "/tmp/project"), + @r" + › Ask Codex to do anything + + ctrl + x … waiting for next key esc cancel + " + ); + + press(&mut app, &mut tui, &mut app_server, ctrl('t')).await?; + assert!(!app.key_chord_matcher.is_pending()); + assert!(app.overlay.is_some()); + Ok(()) +} + +#[tokio::test] +async fn completed_global_chords_toggle_output_and_request_external_editor() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + let config = toml::from_str( + "[global]\ntoggle_raw_output = [\"ctrl-x r\"]\nopen_external_editor = [\"ctrl-x e\"]", + )?; + app.keymap = RuntimeKeymap::from_config(&config).expect("valid global chords"); + app.chat_widget.apply_keymap_update(config, &app.keymap); + + for key in [ctrl('x'), KeyCode::Char('r').into()] { + press(&mut app, &mut tui, &mut app_server, key).await?; + } + assert!(app.chat_widget.raw_output_mode()); + + for key in [ctrl('x'), KeyCode::Char('e').into()] { + press(&mut app, &mut tui, &mut app_server, key).await?; + } + assert_eq!( + app.chat_widget.external_editor_state(), + super::ExternalEditorState::Requested + ); + Ok(()) +} + +#[tokio::test] +async fn wrong_second_stroke_passes_through_but_escape_is_consumed() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + let wrong_second = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE); + assert_eq!( + app.route_key_chord_event(&mut tui, wrong_second), + Some(wrong_second) + ); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(app.route_key_chord_event(&mut tui, escape), None); + assert!(!app.key_chord_matcher.is_pending()); + assert!(!app.backtrack.primed); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn legacy_terminal_recovers_vim_escape_before_normal_commands() -> Result<()> { + for (key, expected) in [ + (KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT), "ab"), + ( + KeyEvent::new(KeyCode::Char('D'), KeyModifiers::ALT | KeyModifiers::SHIFT), + "ab", + ), + (KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), "abc"), + ] { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("abc"); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('i').into(), + ) + .await?; + + press(&mut app, &mut tui, &mut app_server, key).await?; + + assert_eq!(app.chat_widget.composer_text_with_pending(), expected); + assert!( + !app.chat_widget + .should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + ); + } + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn legacy_terminal_preserves_default_and_configured_editor_alt_bindings() -> Result<()> { + for (key, expected) in [ + ('b', "!abc def"), + ('f', "abc! def"), + ('d', "! def"), + ('k', "!"), + ] { + let (mut app, mut tui, mut app_server) = chord_app().await?; + if key == 'k' { + let mut config = TuiKeymap::default(); + config.editor.kill_line_end = + Some(KeybindingsSpec::One(KeybindingSpec("alt-k".to_string()))); + let runtime = RuntimeKeymap::from_config(&config) + .map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; + } + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("abc def"); + let insert = KeyCode::Char('i').into(); + press(&mut app, &mut tui, &mut app_server, insert).await?; + press(&mut app, &mut tui, &mut app_server, ctrl('a')).await?; + press( + &mut app, + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT), + ) + .await?; + + assert!( + app.chat_widget + .should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + ); + app.chat_widget.insert_str("!"); + assert_eq!(app.chat_widget.composer_text_with_pending(), expected); + } + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn legacy_terminal_preserves_active_global_alt_shortcuts() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("abc"); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('i').into(), + ) + .await?; + + press( + &mut app, + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT), + ) + .await?; + + assert!(app.chat_widget.raw_output_mode()); + assert!( + app.chat_widget + .should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + ); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn legacy_terminal_preserves_configured_composer_alt_shortcuts() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + let mut config = TuiKeymap::default(); + config.composer.history_search_previous = + Some(KeybindingsSpec::One(KeybindingSpec("alt-q".to_string()))); + let runtime = + RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("abc"); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('i').into(), + ) + .await?; + + press( + &mut app, + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT), + ) + .await?; + + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("reverse-i-search:")); + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn legacy_terminal_preserves_active_alt_chords() -> Result<()> { + for (binding, prefix, completion) in [ + ( + "alt-q r", + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT), + KeyCode::Char('r').into(), + ), + ( + "ctrl-x alt-h", + ctrl('x'), + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), + ), + ] { + let (mut app, mut tui, mut app_server) = chord_app().await?; + let mut config = TuiKeymap::default(); + config.global.toggle_raw_output = + Some(KeybindingsSpec::One(KeybindingSpec(binding.to_string()))); + let runtime = + RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("abc"); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('i').into(), + ) + .await?; + + press(&mut app, &mut tui, &mut app_server, prefix).await?; + assert!(app.key_chord_matcher.is_pending()); + press(&mut app, &mut tui, &mut app_server, completion).await?; + + assert!(app.chat_widget.raw_output_mode()); + assert!(!app.key_chord_matcher.is_pending()); + } + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn legacy_terminal_preserves_image_paste_without_reading_clipboard() -> Result<()> { + for mode in ['i', 'R'] { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("abc"); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char(mode).into(), + ) + .await?; + for key in [ + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('V'), KeyModifiers::ALT | KeyModifiers::SHIFT), + ] { + assert!(!app.should_recover_vim_insert_escape(key)); + } + assert!(app.should_recover_vim_insert_escape(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::ALT, + ))); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn legacy_terminal_preserves_agent_shortcuts_without_editor_word_bindings() -> Result<()> { + for mode in ['i', 'R'] { + let (mut app, mut tui, mut app_server) = chord_app().await?; + let mut config = TuiKeymap::default(); + config.editor.move_word_left = Some(KeybindingsSpec::Many(vec![])); + config.editor.move_word_right = Some(KeybindingsSpec::Many(vec![])); + let runtime = + RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(config, &runtime); + app.keymap = runtime; + app.chat_widget.toggle_vim_mode_and_notify(); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char(mode).into(), + ) + .await?; + + for key in ['b', 'f'] { + let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT); + assert!(!app.should_recover_vim_insert_escape(event)); + } + app.chat_widget.insert_str("abc"); + for key in ['b', 'f'] { + let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT); + assert!(app.should_recover_vim_insert_escape(event)); + } + } + Ok(()) +} + +#[tokio::test] +async fn vim_escape_recovery_preserves_enhanced_terminals_and_altgr() -> Result<()> { + for (enhanced_keys_supported, modifiers) in [ + (true, KeyModifiers::ALT), + (false, KeyModifiers::ALT | KeyModifiers::CONTROL), + ] { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.enhanced_keys_supported = enhanced_keys_supported; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("abc"); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('i').into(), + ) + .await?; + + press( + &mut app, + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('x'), modifiers), + ) + .await?; + + assert!( + app.chat_widget + .should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + ); + } + Ok(()) +} + +#[cfg(unix)] +#[tokio::test] +async fn recovered_vim_escape_cancels_a_pending_key_chord_first() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("abc"); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('i').into(), + ) + .await?; + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + assert!(app.key_chord_matcher.is_pending()); + + press( + &mut app, + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT), + ) + .await?; + + assert!(!app.key_chord_matcher.is_pending()); + press(&mut app, &mut tui, &mut app_server, KeyCode::Left.into()).await?; + assert_eq!(app.chat_widget.composer_text_with_pending(), "abcx"); + assert!( + app.chat_widget + .should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + ); + Ok(()) +} + +#[tokio::test] +async fn physical_dispatch_band_events_are_dropped() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + let (code, modifiers) = app + .keymap + .app + .open_transcript + .last() + .expect("configured chord appends a dispatch token") + .parts(); + + press( + &mut app, + &mut tui, + &mut app_server, + KeyEvent::new(code, modifiers), + ) + .await?; + + assert!(app.overlay.is_none()); + Ok(()) +} + +#[tokio::test] +async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + app.chat_widget.show_selection_view(SelectionViewParams { + view_id: Some("list"), + items: ["First", "Second"] + .into_iter() + .map(|name| SelectionItem { + name: name.to_string(), + dismiss_on_select: true, + ..Default::default() + }) + .collect(), + ..Default::default() + }); + assert_eq!( + app.chat_widget.selected_index_for_present_view("list"), + Some(0) + ); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + assert!(app.key_chord_matcher.is_pending()); + assert_eq!( + app.chat_widget.selected_index_for_present_view("list"), + Some(0) + ); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('j').into(), + ) + .await?; + assert_eq!( + app.chat_widget.selected_index_for_present_view("list"), + Some(1) + ); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + press(&mut app, &mut tui, &mut app_server, KeyCode::Enter.into()).await?; + assert_eq!( + app.chat_widget.selected_index_for_present_view("list"), + None + ); + + app.chat_widget + .handle_request_user_input_now(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "choice".to_string(), + header: "Pick one".to_string(), + question: "Choose an option.".to_string(), + is_other: false, + is_secret: false, + options: Some( + ["First", "Second"] + .into_iter() + .map(|label| ToolRequestUserInputOption { + label: label.to_string(), + description: label.to_string(), + }) + .collect(), + ), + }], + is_blocking: true, + auto_resolution_ms: None, + }); + let contexts = app.chat_widget.keymap_contexts(); + assert!(contexts.contains(KeymapContext::Chat)); + assert!(contexts.contains(KeymapContext::List)); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("› 1. First")); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + assert!(app.key_chord_matcher.is_pending()); + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char('j').into(), + ) + .await?; + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("› 2. Second")); + + press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?; + press(&mut app, &mut tui, &mut app_server, ctrl('u')).await?; + assert!(app.chat_widget.can_launch_external_editor()); + Ok(()) +} + +#[tokio::test] +async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<()> { + let mut app = make_test_app().await; + app.keymap = + RuntimeKeymap::from_config(&toml::from_str("[agents]\nnew_task = [\"ctrl-x n\"]")?) + .unwrap(); + let mut tui = crate::tui::test_support::make_test_tui()?; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 80); + assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None); + let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + insta::assert_snapshot!( + render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), + @" ctrl + x … waiting for next key esc cancel" + ); + assert_eq!( + app.route_key_chord_event(&mut tui, KeyCode::Esc.into()), + None + ); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 80), before); + Ok(()) +} + +#[tokio::test] +async fn command_center_chords_do_not_capture_search_text() -> Result<()> { + let mut app = make_test_app().await; + app.keymap = RuntimeKeymap::from_config(&toml::from_str( + "[agents]\nnew_task = 'n n'\n[ list ]\naccept = 'ctrl-x enter'", + )?) + .unwrap(); + let mut tui = crate::tui::test_support::make_test_tui()?; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.chat_widget.handle_key_event(KeyCode::Char('f').into()); + for key in "new".chars() { + let event = KeyCode::Char(key).into(); + assert_eq!(app.route_key_chord_event(&mut tui, event), Some(event)); + app.chat_widget.handle_key_event(event); + } + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Search › new")); + assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None); + assert!(app.key_chord_matcher.is_pending()); + app.route_key_chord_event(&mut tui, KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert_eq!( + app.route_key_chord_event(&mut tui, KeyCode::Char('n').into()), + None + ); + assert!(app.key_chord_matcher.is_pending()); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs b/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f56c31af5ca1ebbd5f8f219cdfaf3f5453577d19 --- /dev/null +++ b/codex-rs/tui/src/app/tests/luna_reserve_recovery_tests.rs @@ -0,0 +1,382 @@ +//! Verify Reserve recovery through accepted usage reads and the task settings RPC. + +use super::*; +use crate::chatwidget::UserMessage; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use pretty_assertions::assert_eq; +use serde_json::json; + +pub(super) fn reserve_response() -> GetAccountRateLimitsResponse { + serde_json::from_value(json!({ + "accountId": "workspace-a", "ordinaryUsageAllowed": false, + "rateLimits": {"limitId": "codex", "primary": {"usedPercent": 100}}, + "rateLimitsByLimitId": {"base_model_inference": { + "limitId": "base_model_inference", "limitName": "gpt-reserve", + "primary": {"usedPercent": 48, "windowDurationMins": 10080} + }}, + "rateLimitUpsell": { + "banner_type": "luna_reserve", "presentation": "dismissible", + "title": "You’re now using Luna, a faster model for simpler tasks.", + "description": "Add credits or upgrade to continue using the most advanced models.", + "ctas": [{"action": "add_credits", "label": "Add credits"}] + } + })) + .unwrap() +} + +pub(super) fn configure_reserve_catalog(app: &mut App) { + let mut models = app.model_catalog.try_list_models().unwrap(); + let mut reserve = models + .iter() + .find(|model| model.model == "gpt-5.5") + .unwrap() + .clone(); + reserve.model = "gpt-reserve".into(); + reserve.show_in_picker = false; + models.push(reserve); + let original = models + .iter_mut() + .find(|model| model.model == "gpt-5.5") + .unwrap(); + let mut high = original.supported_reasoning_efforts[0].clone(); + high.effort = ReasoningEffortConfig::High; + original.supported_reasoning_efforts.push(high); + Arc::make_mut(&mut app.model_catalog).models = models; + let mut tui = crate::tui::test_support::make_test_tui().unwrap(); + let mut init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + init.has_chatgpt_account = true; + init.has_codex_backend_auth = true; + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + app.chat_widget + .handle_thread_session(app.primary_session_configured.clone().unwrap()); + app.chat_widget.set_model("gpt-5.5"); +} + +#[tokio::test] +async fn luna_reserve_recovery_survives_task_reconstruction() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::High)); + app.chat_widget.update_backend_banner(&reserve_response()); + app.apply_backend_banner_fallback(&mut server).await; + let mut resumed = app.primary_session_configured.clone().unwrap(); + resumed.model = "gpt-reserve".into(); + resumed.collaboration_mode = Some(Box::new(app.chat_widget.effective_collaboration_mode())); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + init.has_chatgpt_account = true; + init.has_codex_backend_auth = true; + // No in-memory transition state is inherited: resume must recover the persisted target. + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + app.chat_widget.handle_thread_session(resumed); + assert_eq!(app.chat_widget.current_model(), "gpt-reserve"); + let mut recovered = reserve_response(); + recovered.ordinary_usage_allowed = Some(true); + recovered.rate_limit_upsell = None; + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.5"); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + requests + .lock() + .unwrap() + .iter() + .filter(|request| request.method == "thread/settings/update") + .count(), + 2 + ); + assert!( + !app.config + .codex_home + .join("tui-luna-reserve") + .join(format!("{}.json", app.active_thread_id.unwrap())) + .exists() + ); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn luna_reserve_entry_dispatches_an_already_queued_turn_with_accepted_settings() -> Result<()> +{ + for mode in [ModeKind::Default, ModeKind::Plan] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ true); + app.chat_widget.set_service_tier(Some("fast".into())); + if mode == ModeKind::Plan { + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + } + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("continue")); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + let pending = std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + .expect("queued turn before the usage response"); + requests.lock().unwrap().clear(); + let mut tui = crate::tui::test_support::make_test_tui()?; + let generation = app.rate_limit_hard_stop_generation; + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::Periodic, + hard_stop_generation: generation, + result: Ok(reserve_response()), + }, + ) + .await?; + app.handle_event(&mut tui, &mut server, pending).await?; + let sent = requests.lock().unwrap().clone(); + let methods: Vec<_> = sent.iter().map(|request| request.method.as_str()).collect(); + assert_eq!(methods, ["thread/settings/update", "turn/start"]); + let settings: ThreadSettingsUpdateParams = + serde_json::from_value(sent[0].params.clone().unwrap())?; + let turn: codex_app_server_protocol::TurnStartParams = + serde_json::from_value(sent[1].params.clone().unwrap())?; + assert_eq!( + ( + turn.model, + turn.effort, + turn.service_tier, + turn.collaboration_mode + ), + ( + settings.model, + settings.effort, + settings.service_tier, + settings.collaboration_mode + ) + ); + assert_eq!(app.chat_widget.effective_collaboration_mode().mode, mode); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn luna_reserve_recovery_restores_task_and_pending_turn_after_fresh_backend_read() +-> Result<()> { + for has_credits in [false, true] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ false); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::High)); + let original_mode = app.chat_widget.effective_collaboration_mode(); + let config_path = app.config.codex_home.join("config.toml"); + let saved_config = std::fs::read(&config_path).ok(); + let mut tui = crate::tui::test_support::make_test_tui()?; + requests.lock().unwrap().clear(); + let generation = app.rate_limit_hard_stop_generation; + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id: 1, + origin: RateLimitRefreshOrigin::Periodic, + hard_stop_generation: generation, + result: Ok(reserve_response()), + }, + ) + .await?; + assert_eq!(app.chat_widget.current_model(), "gpt-reserve"); + + // A prompt composed before the next accepted read must use the recovered model, once. + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Esc)); + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("continue")); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + let mut pending = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::CodexOp(op @ AppCommand::UserTurn { .. }) => Some(op), + _ => None, + }) + .expect("queued user turn"); + assert_matches!(&pending, AppCommand::UserTurn {model, ..} if model == "gpt-reserve"); + while events.try_recv().is_ok() {} + + let mut recovered = reserve_response(); + recovered.ordinary_usage_allowed = Some(!has_credits); + recovered.rate_limits.credits = Some(codex_app_server_protocol::CreditsSnapshot { + has_credits, + unlimited: false, + balance: None, + }); + recovered.rate_limit_upsell = None; + // A rounded percentage is not an authorization decision; backend allowed=true wins. + for (request_id, hard_stop_generation, expected) in [ + (2, generation.wrapping_sub(1), "gpt-reserve"), + (3, generation, "gpt-5.5"), + (4, generation, "gpt-5.5"), + ] { + app.handle_event( + &mut tui, + &mut server, + AppEvent::RateLimitsLoaded { + request_id, + origin: RateLimitRefreshOrigin::Periodic, + hard_stop_generation, + result: Ok(recovered.clone()), + }, + ) + .await?; + assert_eq!(app.chat_widget.current_model(), expected); + } + assert_eq!( + app.chat_widget.effective_collaboration_mode(), + original_mode + ); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Luna Reserve")); + app.chat_widget + .apply_reserve_fallback_to_pending_turn(&mut pending); + assert_matches!(pending, AppCommand::UserTurn { model, effort, collaboration_mode, .. } + if model == "gpt-5.5" && effort == original_mode.reasoning_effort() + && collaboration_mode == Some(original_mode.clone())); + let sent = requests.lock().unwrap().clone(); + assert_eq!(sent.len(), 2); + let restored: ThreadSettingsUpdateParams = + serde_json::from_value(sent[1].params.clone().unwrap())?; + assert_eq!( + restored, + ThreadSettingsUpdateParams { + thread_id: app.active_thread_id.unwrap().to_string(), + model: Some("gpt-5.5".into()), + effort: Some(ReasoningEffortConfig::High), + collaboration_mode: Some(original_mode), + service_tier: None, + ..Default::default() + } + ); + assert_eq!(std::fs::read(&config_path).ok(), saved_config); + let queued = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + assert!( + !queued.iter().any(|event| matches!( + event, + AppEvent::CodexOp(_) | AppEvent::PersistModelSelection { .. } + )), + "recovery must not replay a turn or change global defaults" + ); + let notice = queued + .iter() + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 100))) + } + _ => None, + }) + .expect("recovery notice"); + insta::assert_snapshot!("luna_reserve_recovery_notice", notice); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn luna_reserve_recovery_requires_permission_and_no_remaining_blocker() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget.update_backend_banner(&reserve_response()); + app.apply_backend_banner_fallback(&mut server).await; + requests.lock().unwrap().clear(); + let mut recovered = reserve_response(); + recovered.rate_limit_upsell = None; + recovered.rate_limits.primary.as_mut().unwrap().used_percent = 0; + recovered.rate_limits.primary.as_mut().unwrap().resets_at = Some(0); + for allowed in [None, Some(false)] { + recovered.ordinary_usage_allowed = allowed; + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-reserve"); + } + recovered.ordinary_usage_allowed = Some(true); + for blocker in [ + json!({"accountId": null, "ordinaryUsageAllowed": null}), + json!({"rateLimitUpsell": {"unsupported": true}}), + json!({"rateLimits": {"spendControlReached": true}}), + json!({"rateLimits": {"rateLimitReachedType": "workspace_owner_usage_limit_reached"}}), + ] { + let mut raw = serde_json::to_value(&recovered)?; + for (key, value) in blocker.as_object().unwrap() { + raw[key] = value.clone(); + } + app.chat_widget + .update_backend_banner(&serde_json::from_value(raw)?); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-reserve"); + } + // Reset redemption clears presentation before the authoritative follow-up read. + app.chat_widget.clear_backend_banner(); + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), "gpt-5.5"); + assert_eq!(requests.lock().unwrap().len(), 1); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn luna_reserve_recovery_does_not_override_manual_choice_or_account_change() -> Result<()> { + for change_account in [false, true] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = + backend_banner_fallback_tests::start_fallback_thread(&mut app).await?; + configure_reserve_catalog(&mut app); + app.chat_widget.update_backend_banner(&reserve_response()); + app.apply_backend_banner_fallback(&mut server).await; + requests.lock().unwrap().clear(); + let mut recovered = reserve_response(); + recovered.ordinary_usage_allowed = Some(true); + recovered.rate_limit_upsell = None; + let expected = if change_account { + recovered.account_id = Some("workspace-b".into()); + "gpt-reserve" + } else { + app.chat_widget.set_model("gpt-5.6-terra"); + "gpt-5.6-terra" + }; + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + recovered.account_id = Some("workspace-a".into()); + app.chat_widget.update_backend_banner(&recovered); + app.apply_backend_banner_fallback(&mut server).await; + assert_eq!(app.chat_widget.current_model(), expected); + assert!(requests.lock().unwrap().is_empty()); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/math_interruption_tests.rs b/codex-rs/tui/src/app/tests/math_interruption_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e33c0809f5fafc1ae9c5cb7c4d054ebb3a04f19 --- /dev/null +++ b/codex-rs/tui/src/app/tests/math_interruption_tests.rs @@ -0,0 +1,112 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn unicode_math_termination_preserves_source_through_mode_and_resize() -> Result<()> { + for (kind, status) in [ + ("answer", TurnStatus::Interrupted), + ("plan", TurnStatus::Interrupted), + ("plan_error", TurnStatus::Failed), + ("plan_policy", TurnStatus::Failed), + ] { + for ending in ["\n", ""] { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let thread_id = ThreadId::new(); + let plan = kind != "answer"; + if plan { + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Plan".into(), + mode: Some(ModeKind::Plan), + model: None, + reasoning_effort: None, + developer_instructions: None, + }); + } + app.chat_widget.handle_server_notification( + turn_started_notification(thread_id, "math-turn"), + /*replay_kind*/ None, + ); + let source = format!("Intro.\n\n$$\n\\frac{{a+b+c+d+e+f}}{{g+h}}{ending}"); + for delta in ["Intro.\n\n", &source[8..]] { + let notification = if plan { + ServerNotification::PlanDelta( + codex_app_server_protocol::PlanDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: "math-turn".into(), + item_id: "math-message".into(), + delta: delta.into(), + }, + ) + } else { + agent_message_delta_notification(thread_id, "math-turn", "math-message", delta) + }; + app.chat_widget + .handle_server_notification(notification, /*replay_kind*/ None); + for _ in 0..10 { + app.chat_widget.on_commit_tick(); + } + } + let mut completed = turn_completed_notification(thread_id, "math-turn", status.clone()); + if matches!(kind, "plan_error" | "plan_policy") + && let ServerNotification::TurnCompleted(notification) = &mut completed + { + notification.turn.error = Some(AppServerTurnError { + message: if kind == "plan_policy" { + r#"{"error":{"code":"bio_policy"}}"#.into() + } else { + "Server error".into() + }, + codex_error_info: None, + additional_details: None, + misalignment: None, + }); + } + app.chat_widget + .handle_server_notification(completed, /*replay_kind*/ None); + let mut saved = Vec::new(); + while let Ok(event) = events.try_recv() { + if let AppEvent::ConsolidateAgentMessage { source, .. } + | AppEvent::ConsolidateProposedPlan(source) = &event + { + saved.push(source.trim_end_matches('\n').to_owned()); + } + if matches!( + event, + AppEvent::InsertHistoryCell(_) + | AppEvent::ConsolidateAgentMessage { .. } + | AppEvent::ConsolidateProposedPlan(_) + ) { + app.handle_event(&mut tui, &mut app_server, event).await?; + } + } + assert_eq!(saved, vec![source.trim_end_matches('\n').to_owned()]); + let mut expected = [None, None]; + for raw in [false, true, false] { + app.chat_widget.set_raw_output_mode(raw); + for width in [80, 20, 80] { + let rendered = app + .render_transcript_lines_for_reflow(width) + .lines + .iter() + .map(rendered_line_text) + .collect::>() + .join("\n"); + let content = rendered.split_whitespace().collect::(); + assert_eq!( + expected[usize::from(raw)].get_or_insert_with(|| content.clone()), + &content + ); + if !raw && width == 20 && !ending.is_empty() && kind != "plan_policy" { + insta::assert_snapshot!(format!("{kind}_math_narrow"), rendered); + } + } + } + } + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/mcp_startup.rs b/codex-rs/tui/src/app/tests/mcp_startup.rs new file mode 100644 index 0000000000000000000000000000000000000000..3c355bb8e2aca428ff9a482bc206bdb0804d9c11 --- /dev/null +++ b/codex-rs/tui/src/app/tests/mcp_startup.rs @@ -0,0 +1,156 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn configure_mcp_servers(app: &mut App) { + let config: codex_config::types::McpServerConfig = + toml::from_str::("command = 'true'") + .expect("test MCP config should parse") + .try_into() + .expect("test MCP config should deserialize"); + app.config + .mcp_servers + .set(HashMap::from([ + ("eager".to_string(), config.clone()), + ("deferred".to_string(), config), + ])) + .expect("test MCP servers should accept any configuration"); +} + +#[tokio::test] +async fn subagent_mcp_startup_settles_while_cached_servers_remain_deferred() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + configure_mcp_servers(&mut app); + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let root_thread_id = ThreadId::new(); + let subagent_thread_id = ThreadId::new(); + app.primary_thread_id = Some(root_thread_id); + app.upsert_agent_picker_thread( + subagent_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); + app.ensure_thread_channel(subagent_thread_id); + app.activate_thread_channel(subagent_thread_id).await; + app.replay_thread_snapshot( + ThreadEventSnapshot { + delegated_turns: Vec::new(), + session: Some(test_thread_session( + subagent_thread_id, + test_path_buf("/tmp/subagent"), + )), + turns: Vec::new(), + events: Vec::new(), + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + + let mut visible_startup_states = Vec::new(); + for (name, status, task_running) in [ + ("eager", McpServerStartupState::Starting, true), + ("eager", McpServerStartupState::Ready, false), + ("deferred", McpServerStartupState::Starting, true), + ("deferred", McpServerStartupState::Ready, false), + ] { + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some(subagent_thread_id.to_string()), + name: name.to_string(), + status, + error: None, + failure_reason: None, + }), + )), + ) + .await; + let event = app + .active_thread_rx + .as_mut() + .expect("subagent receiver should be active") + .try_recv() + .expect("MCP startup update should reach the active subagent"); + app.handle_thread_event_now(event); + + assert_eq!(app.chat_widget.is_task_running_for_test(), task_running); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); + let visible_status = rendered + .lines() + .find(|line| line.contains("Booting MCP server:")) + .and_then(|line| line.split_once(" (")) + .map_or("idle", |(status, _)| status); + visible_startup_states.push(format!("{name}: {visible_status}")); + } + + insta::assert_snapshot!(visible_startup_states.join("\n"), @r" + eager: idle + eager: idle + deferred: idle + deferred: idle + "); +} + +#[tokio::test] +async fn resumed_subagent_mcp_startup_settles_while_cached_servers_remain_deferred() { + let mut app = make_test_app().await; + configure_mcp_servers(&mut app); + let subagent_thread_id = ThreadId::new(); + app.primary_thread_id = Some(subagent_thread_id); + app.active_thread_id = Some(subagent_thread_id); + app.agent_navigation.mark_parent_owned(subagent_thread_id); + app.refresh_mcp_startup_expected_servers_from_config(); + + for status in [ + McpServerStartupState::Starting, + McpServerStartupState::Ready, + ] { + app.chat_widget.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some(subagent_thread_id.to_string()), + name: "eager".to_string(), + status, + error: None, + failure_reason: None, + }), + /*replay_kind*/ None, + ); + } + + assert!(!app.chat_widget.is_task_running_for_test()); +} + +#[tokio::test] +async fn side_conversations_wait_for_every_configured_mcp_server() { + let mut app = make_test_app().await; + configure_mcp_servers(&mut app); + let root_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.primary_thread_id = Some(root_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(root_thread_id)); + app.active_thread_id = Some(side_thread_id); + app.refresh_mcp_startup_expected_servers_from_config(); + + for status in [ + McpServerStartupState::Starting, + McpServerStartupState::Ready, + ] { + app.chat_widget.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: Some(side_thread_id.to_string()), + name: "eager".to_string(), + status, + error: None, + failure_reason: None, + }), + /*replay_kind*/ None, + ); + } + + assert!(app.chat_widget.is_task_running_for_test()); +} diff --git a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..48c7bfaf396ea5ab218eac0a6b7601f4e5b51f0e --- /dev/null +++ b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs @@ -0,0 +1,209 @@ +use super::session_lifecycle_requests::recorded_params; +use super::session_lifecycle_requests::start_recording_app_server; +use super::*; +use codex_app_server_protocol::MisalignmentErrorDetails; +use codex_app_server_protocol::MisalignmentSteer; +use pretty_assertions::assert_eq; + +fn policy_error() -> AppServerTurnError { + AppServerTurnError { + message: "Chat paused".into(), + codex_error_info: Some(AppServerCodexErrorInfo::MisalignmentPolicyViolation), + additional_details: None, + misalignment: Some(MisalignmentErrorDetails { + error_type: None, + detailed_explanation: Some( + "The proposed action exceeded the request.\n\n".repeat(1_500), + ), + steer: Some(MisalignmentSteer { + message: "Continue **only** within the requested scope.\nDo not edit files.".into(), + }), + }), + } +} + +fn error_notification( + thread_id: ThreadId, + turn_id: &str, + error: AppServerTurnError, +) -> ServerNotification { + ServerNotification::Error(codex_app_server_protocol::ErrorNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.into(), + error, + will_retry: false, + }) +} + +#[tokio::test] +async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> { + for reject in [false, true] { + let (mut app, mut rx, _) = make_test_app_with_channels().await; + let (mut server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let mut session = if reject { + test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()) + } else { + server.start_thread(&app.config).await?.session + }; + let thread_id = session.thread_id; + // Resend local restrictions restored after reconnect, rather than inheriting server settings. + session.approval_policy = AskForApproval::OnRequest; + session.permission_profile = PermissionProfile::read_only(); + session.active_permission_profile = None; + app.active_thread_id = Some(thread_id); + app.chat_widget.handle_thread_session(session); + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + ); + app.chat_widget.handle_server_notification( + error_notification(thread_id, "failed-turn", policy_error()), + /*replay_kind*/ None, + ); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::Enter)); + let review = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::ReviewMisalignment(review) => Some(review), + _ => None, + }) + .expect("review action"); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_event( + &mut tui, + &mut server, + AppEvent::ReviewMisalignment(Arc::clone(&review)), + ) + .await?; + let Some(Overlay::Static(overlay)) = app.overlay.as_mut() else { + panic!("findings overlay") + }; + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new( + /*width*/ 38, /*height*/ 16, + ))?; + terminal.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))?; + insta::assert_snapshot!("misalignment_findings_38", terminal.backend()); + app.overlay = None; + assert!(recorded_params(&requests, "turn/start").is_empty()); + let mut withdrawn = policy_error(); + withdrawn + .misalignment + .as_mut() + .unwrap() + .detailed_explanation = None; + // Reject both a newer turn and withdrawn findings followed by an empty duplicate. + for notifications in [ + vec![turn_completed_notification( + thread_id, + "newer-turn", + TurnStatus::Completed, + )], + vec![ + error_notification(thread_id, &review.turn_id, withdrawn.clone()), + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + error: Some(AppServerTurnError { + misalignment: None, + ..withdrawn + }), + ..test_turn(&review.turn_id, TurnStatus::Failed, Vec::new()) + }, + }), + ], + ] { + for notification in notifications { + app.enqueue_thread_notification(thread_id, notification) + .await?; + } + app.continue_misalignment(&mut server, Arc::clone(&review)) + .await; + assert!(recorded_params(&requests, "turn/start").is_empty()); + app.thread_event_channels.remove(&thread_id); + } + + if reject { + app.continue_misalignment(&mut server, Arc::clone(&review)) + .await; + assert!(app.chat_widget.has_misalignment_policy_violation()); + let errors = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!( + "misalignment_continue_failed", + format!( + "{errors}\n{}", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + ); + server.shutdown().await?; + proxy.await??; + continue; + } + // A settings error is not a new turn and must not silently disable Continue. + app.enqueue_thread_notification( + thread_id, + error_notification( + thread_id, + "settings-update", + AppServerTurnError { + codex_error_info: Some(AppServerCodexErrorInfo::BadRequest), + ..policy_error() + }, + ), + ) + .await?; + app.continue_misalignment(&mut server, Arc::clone(&review)) + .await; + app.continue_misalignment(&mut server, Arc::clone(&review)) + .await; + assert!(!app.chat_widget.has_misalignment_policy_violation()); + let params = recorded_params(&requests, "turn/start"); + let [params] = params.as_slice() else { + panic!("expected one turn/start: {params:?}") + }; + let mut params: codex_app_server_protocol::TurnStartParams = + serde_json::from_value(params.clone())?; + let metadata = params.responsesapi_client_metadata.take().unwrap(); + let override_value: serde_json::Value = + serde_json::from_str(&metadata["misalignment_override"])?; + let timestamp = override_value["timestamp"].as_u64().unwrap(); + assert_eq!(metadata.len(), 1); + assert_eq!(override_value, serde_json::json!({"timestamp": timestamp})); + let config = app.chat_widget.config_ref(); + assert_eq!( + params, + codex_app_server_protocol::TurnStartParams { + thread_id: thread_id.to_string(), + cwd: Some(config.cwd.to_path_buf()), + runtime_workspace_roots: Some( + config.permissions.user_visible_workspace_roots().to_vec() + ), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(config.approvals_reviewer.into()), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false + }), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Continue **only** within the requested scope.\nDo not edit files." + .to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + } + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs new file mode 100644 index 0000000000000000000000000000000000000000..467b24b22ae669234e7a80c390e5401a45decd38 --- /dev/null +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -0,0 +1,566 @@ +use super::*; +use assert_matches::assert_matches; +use codex_config::types::ModelAvailabilityNuxConfig; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ModelAvailabilityNux; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::unbounded_channel; + +fn all_model_presets() -> Vec { + crate::test_support::TEST_MODEL_PRESETS.clone() +} + +#[tokio::test] +async fn model_picker_refresh_updates_app_catalog_from_app_server() -> Result<()> { + let (mut app, mut rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let fast = Some(ServiceTier::Fast.request_value()); + let thread_id = ThreadId::new(); + let mut session = test_thread_session(thread_id, test_path_buf("/tmp/project")); + session.model = "gpt-5.5".to_string(); + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + app.primary_session_configured = Some(session.clone()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session(/*capacity*/ 4, session.clone(), Vec::new()), + ); + app.chat_widget.handle_thread_session(session.clone()); + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ true); + app.chat_widget.open_model_popup(); + let request_id = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::FetchModels { request_id } => Some(request_id), + _ => None, + }) + .expect("initial model request"); + let mut hidden = all_model_presets(); + for preset in &mut hidden { + preset.show_in_picker = false; + } + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ModelsLoaded { + request_id, + result: Ok(hidden), + }, + )) + .await?; + app.chat_widget.open_model_popup(); + assert!(app.chat_widget.no_modal_or_popup_active()); + let request = std::iter::from_fn(|| rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::FetchModels { .. })) + .expect("catalog request"); + + Box::pin(app.handle_event(&mut tui, &mut app_server, request)).await?; + let mut loaded = tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async { + loop { + if let Some(event @ AppEvent::ModelsLoaded { .. }) = rx.recv().await { + break event; + } + } + }) + .await?; + let AppEvent::ModelsLoaded { + result: Ok(models), .. + } = &mut loaded + else { + panic!("model/list should return models"); + }; + assert!(!models.is_empty()); + for model in models.iter_mut() { + model.is_default = model.model == "gpt-5.5"; + model.default_service_tier = model + .is_default + .then(|| ServiceTier::Fast.request_value().to_string()); + } + let expected = models.clone(); + Box::pin(app.handle_event(&mut tui, &mut app_server, loaded)).await?; + assert!(app.chat_widget.no_modal_or_popup_active()); + app.chat_widget.open_model_popup(); + assert!(!app.chat_widget.no_modal_or_popup_active()); + + session.service_tier = fast.map(str::to_string); + let channel = &app.thread_event_channels[&thread_id]; + let cached = channel.store.lock().await.session.clone(); + assert_eq!( + (&app.primary_session_configured, &cached), + (&Some(session.clone()), &Some(session.clone())) + ); + app.chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + test_path_buf("/tmp/other"), + )); + app.chat_widget.handle_thread_session(cached.unwrap()); + assert_eq!(app.chat_widget.current_service_tier(), fast); + let stale = AppEvent::ModelsLoaded { + request_id: uuid::Uuid::new_v4(), + result: Ok(all_model_presets()), + }; + Box::pin(app.handle_event(&mut tui, &mut app_server, stale)).await?; + assert_eq!( + ( + app.model_catalog.try_list_models().unwrap(), + app.chat_widget.model_catalog().try_list_models().unwrap(), + ), + (expected.clone(), expected) + ); + let mut config = app.config.clone(); + config.service_tier = None; + config.features.enable(Feature::FastMode)?; + for (model, expected_tier) in [(None, fast), (Some("gpt-5.6-terra"), Some("default"))] { + config.model = model.map(str::to_string); + let started = app_server.start_thread(&config).await?; + assert_eq!(started.session.service_tier.as_deref(), expected_tier); + } + app_server.shutdown().await?; + Ok(()) +} + +fn model_presets_with_test_upgrades() -> Vec { + let mut presets = all_model_presets(); + let mut preset = presets + .iter() + .find(|preset| preset.model == "gpt-5.5") + .expect("current model template") + .clone(); + // Catalog-provided migration metadata is independent of the retired model's prompts. + preset.id = "gpt-5.2".to_string(); + preset.model = "gpt-5.2".to_string(); + preset.upgrade = Some(ModelUpgrade { + id: "gpt-5.5".to_string(), + migration_config_key: "hide_test_migration_prompt".to_string(), + model_link: None, + upgrade_copy: None, + migration_markdown: None, + retirement_at: None, + }); + presets.push(preset); + presets +} + +fn model_availability_nux_config(shown_count: &[(&str, u32)]) -> ModelAvailabilityNuxConfig { + ModelAvailabilityNuxConfig { + shown_count: shown_count + .iter() + .map(|(model, count)| ((*model).to_string(), *count)) + .collect(), + } +} + +fn model_migration_copy_to_plain_text(copy: &crate::model_migration::ModelMigrationCopy) -> String { + if let Some(markdown) = copy.markdown.as_ref() { + return markdown.clone(); + } + let mut s = String::new(); + for span in ©.heading { + s.push_str(&span.content); + } + s.push('\n'); + s.push('\n'); + for line in ©.content { + for span in &line.spans { + s.push_str(&span.content); + } + s.push('\n'); + } + s +} + +#[tokio::test] +async fn model_migration_prompt_only_shows_for_deprecated_models() { + let seen = BTreeMap::new(); + let presets = model_presets_with_test_upgrades(); + assert!(should_show_model_migration_prompt( + "gpt-5.2", "gpt-5.5", &seen, &presets + )); + assert!(should_show_model_migration_prompt( + "gpt-5.4", + "gpt-5.6-terra", + &seen, + &presets + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.4", "gpt-5.4", &seen, &presets + )); +} + +#[test] +fn retired_model_migration_respects_catalog_metadata() { + let mut presets = model_presets_with_test_upgrades(); + let current = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.2") + .expect("test preset"); + current.id = "gpt-5.4-mini".to_string(); + current.model = "gpt-5.4-mini".to_string(); + let expected = current.upgrade.clone(); + assert_eq!( + model_upgrade_for_migration("gpt-5.4-mini", &presets), + expected + ); + + presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.4-mini") + .expect("catalog preset") + .upgrade = None; + assert_eq!(model_upgrade_for_migration("gpt-5.4-mini", &presets), None); +} + +#[test] +fn select_model_availability_nux_picks_only_eligible_model() { + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let target = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.6-terra") + .expect("target preset present"); + target.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.6-terra is available".to_string(), + }); + + let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[])); + + assert_eq!( + selected, + Some(StartupTooltipOverride { + model_slug: "gpt-5.6-terra".to_string(), + message: "gpt-5.6-terra is available".to_string(), + }) + ); +} + +#[test] +fn select_model_availability_nux_does_not_fall_back_to_older_announcement() { + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let gpt_5 = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.6-terra") + .expect("gpt-5.6-terra preset present"); + gpt_5.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.6-terra is available".to_string(), + }); + let gpt_5_2 = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.5") + .expect("gpt-5.5 preset present"); + gpt_5_2.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.5 is available".to_string(), + }); + + let selected = select_model_availability_nux( + &presets, + &model_availability_nux_config(&[("gpt-5.6-terra", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]), + ); + + assert_eq!(selected, None); +} + +#[test] +fn select_model_availability_nux_uses_existing_model_order_as_priority() { + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let first = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.5") + .expect("gpt-5.5 preset present"); + first.availability_nux = Some(ModelAvailabilityNux { + message: "first".to_string(), + }); + let second = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.6-terra") + .expect("gpt-5.6-terra preset present"); + second.availability_nux = Some(ModelAvailabilityNux { + message: "second".to_string(), + }); + + let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[])); + + assert_eq!( + selected, + Some(StartupTooltipOverride { + model_slug: "gpt-5.6-terra".to_string(), + message: "second".to_string(), + }) + ); +} + +#[test] +fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() { + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let target = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.6-terra") + .expect("target preset present"); + target.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.6-terra is available".to_string(), + }); + + let selected = select_model_availability_nux( + &presets, + &model_availability_nux_config(&[("gpt-5.6-terra", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]), + ); + + assert_eq!(selected, None); +} + +#[tokio::test] +async fn prepare_startup_tooltip_override_persists_model_availability_nux_count() { + let codex_home = tempdir().expect("temp codex home"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + let mut presets = all_model_presets(); + presets.iter_mut().for_each(|preset| { + preset.availability_nux = None; + }); + let target = presets + .iter_mut() + .find(|preset| preset.model == "gpt-5.6-terra") + .expect("target preset present"); + target.availability_nux = Some(ModelAvailabilityNux { + message: "gpt-5.6-terra is available".to_string(), + }); + + let mut local_settings = crate::local_settings::LocalSettings::from(&config); + let tooltip = prepare_startup_tooltip_override( + &mut local_settings, + &presets, + /*is_first_run*/ false, + ) + .await; + + assert_eq!(tooltip.as_deref(), Some("gpt-5.6-terra is available")); + assert_eq!( + local_settings.tui.model_availability_nux.shown_count, + HashMap::from([("gpt-5.6-terra".to_string(), 1)]) + ); + + let reloaded = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("reloaded config"); + assert_eq!( + reloaded.model_availability_nux.shown_count, + HashMap::from([("gpt-5.6-terra".to_string(), 1)]) + ); +} + +#[tokio::test] +async fn accepted_model_migration_persists_target_default_reasoning_effort() -> Result<()> { + let presets = all_model_presets(); + let mut migration_copies = Vec::new(); + for (model, replacement) in [ + ("gpt-5.4", "gpt-5.6-terra"), + ("gpt-5.4-mini", "gpt-5.6-luna"), + ] { + let codex_home = tempdir()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!("model = \"{model}\"\nmodel_reasoning_effort = \"xhigh\"\n"), + )?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + let current_model = config.model.clone().expect("saved model"); + let upgrade = model_upgrade_for_migration(¤t_model, &presets) + .expect("retired selection should still migrate"); + assert_eq!(upgrade.id, replacement); + assert!(should_show_model_migration_prompt( + ¤t_model, + &upgrade.id, + &BTreeMap::new(), + &presets, + )); + let target = + target_preset_for_upgrade(&presets, &upgrade.id).expect("available replacement"); + let target_effort = target.default_reasoning_effort.clone(); + let can_opt_out = presets.iter().any(|preset| preset.model == current_model); + let copy = migration_copy_for_models( + ¤t_model, + &upgrade.id, + upgrade.model_link, + upgrade.upgrade_copy, + upgrade.migration_markdown, + target.display_name.clone(), + Some(target.description.clone()), + can_opt_out, + ); + migration_copies.push(model_migration_copy_to_plain_text(©)); + + let (tx_raw, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx_raw); + apply_accepted_model_migration( + &mut config, + &app_event_tx, + current_model, + upgrade.id, + target_effort.clone(), + ); + + assert_eq!(config.model.as_deref(), Some(replacement)); + assert_eq!(config.model_reasoning_effort, Some(target_effort.clone())); + + let acknowledged = rx.try_recv().expect("acknowledged event"); + assert_matches!( + acknowledged, + AppEvent::PersistModelMigrationPromptAcknowledged { from_model, to_model } + if from_model == model && to_model == replacement + ); + + let update_model = rx.try_recv().expect("update model event"); + assert_matches!( + update_model, + AppEvent::UpdateModel(updated_model) if updated_model == replacement + ); + + let update_effort = rx.try_recv().expect("update effort event"); + assert_matches!( + update_effort, + AppEvent::UpdateReasoningEffort(Some(effort)) if effort == target_effort + ); + + let persist_selection = rx.try_recv().expect("persist model selection event"); + assert_matches!( + persist_selection, + AppEvent::PersistModelSelection { model: selected_model, effort } + if selected_model == replacement && effort == Some(target_effort) + ); + } + assert_snapshot!(migration_copies.join("\n"), @r" + GPT-5.4 is no longer available + + Codex now uses GPT-5.6 Terra in place of GPT-5.4. Switch to GPT-5.6 Terra to continue. + + GPT-5.4 Mini is no longer available + + Codex now uses GPT-5.6 Luna in place of GPT-5.4 Mini. Switch to GPT-5.6 Luna to continue. + "); + Ok(()) +} + +#[tokio::test] +async fn model_migration_prompt_respects_hide_flag_and_self_target() { + let presets = all_model_presets(); + let mut seen = BTreeMap::new(); + seen.insert("gpt-5.4-mini".to_string(), "gpt-5.6-luna".to_string()); + assert!(!should_show_model_migration_prompt( + "gpt-5.4-mini", + "gpt-5.6-luna", + &seen, + &presets + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.6-luna", + "gpt-5.6-luna", + &seen, + &presets + )); +} + +#[tokio::test] +async fn model_migration_prompt_skips_when_target_missing_or_hidden() { + let mut available = all_model_presets(); + available.retain(|preset| preset.model != "gpt-5.6-luna"); + + assert!(!should_show_model_migration_prompt( + "gpt-5.4-mini", + "gpt-5.6-luna", + &BTreeMap::new(), + &available, + )); + + assert!(target_preset_for_upgrade(&available, "gpt-5.6-luna").is_none()); + + let mut with_hidden_target = all_model_presets(); + let target = with_hidden_target + .iter_mut() + .find(|preset| preset.model == "gpt-5.6-luna") + .expect("target preset present"); + target.show_in_picker = false; + + assert!(!should_show_model_migration_prompt( + "gpt-5.4-mini", + "gpt-5.6-luna", + &BTreeMap::new(), + &with_hidden_target, + )); + assert!(target_preset_for_upgrade(&with_hidden_target, "gpt-5.6-luna").is_none()); +} + +#[tokio::test] +async fn model_migration_prompt_shows_for_hidden_model() { + let codex_home = tempdir().expect("temp codex home"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + + let mut available_models = model_presets_with_test_upgrades(); + let current = available_models + .iter_mut() + .find(|preset| preset.model == "gpt-5.2") + .expect("gpt-5.2 preset present"); + current.show_in_picker = false; + let current = current.clone(); + assert!( + !current.show_in_picker, + "expected gpt-5.2 to be hidden from picker for this test" + ); + + let upgrade = current.upgrade.as_ref().expect("upgrade configured"); + available_models + .iter_mut() + .find(|preset| preset.model == upgrade.id) + .expect("upgrade target present") + .show_in_picker = true; + assert!( + should_show_model_migration_prompt( + ¤t.model, + &upgrade.id, + &config.notices.model_migrations, + &available_models, + ), + "expected migration prompt to be eligible for hidden model" + ); + + let target = + target_preset_for_upgrade(&available_models, &upgrade.id).expect("upgrade target present"); + let target_description = (!target.description.is_empty()).then(|| target.description.clone()); + let can_opt_out = true; + let copy = migration_copy_for_models( + ¤t.model, + &upgrade.id, + upgrade.model_link.clone(), + upgrade.upgrade_copy.clone(), + upgrade.migration_markdown.clone(), + target.display_name.clone(), + target_description, + can_opt_out, + ); + + assert_snapshot!( + "model_migration_prompt_shows_for_hidden_model", + model_migration_copy_to_plain_text(©) + ); +} diff --git a/codex-rs/tui/src/app/tests/model_defaults_tests.rs b/codex-rs/tui/src/app/tests/model_defaults_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b69291c2a8d45c130f4d09920cd3e3a389b1bf7 --- /dev/null +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -0,0 +1,210 @@ +use super::*; +use crate::collaboration_modes; +use codex_app_server_client::AppServerClient; +use codex_config::LoaderOverrides; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn session_model_selection_preserves_defaults_and_updates_active_thread() -> Result<()> { + for mode in [ModeKind::Default, ModeKind::Plan] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config.model = Some("gpt-5.5".into()); + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); + app.config.plan_mode_reasoning_effort = Some(ReasoningEffortConfig::Low); + let config_path = app.config.codex_home.join("config.toml"); + let original = "# My defaults\nmodel = 'gpt-5.5'\nmodel_reasoning_effort = 'medium'\nplan_mode_reasoning_effort = 'low'\n"; + std::fs::write(&config_path, original)?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let started = server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + if mode == ModeKind::Plan { + let mask = collaboration_modes::plan_mask(app.model_catalog.as_ref()).unwrap(); + app.chat_widget.set_collaboration_mask(mask); + } + let mut tui = crate::tui::test_support::make_test_tui()?; + for effort in [ + ReasoningEffortConfig::High, + ReasoningEffortConfig::Ultra, + ReasoningEffortConfig::Max, + ] { + while events.try_recv().is_ok() {} + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::SelectSessionModel { + model: "gpt-5.6-terra".into(), + effort: Some(effort.clone()), + }, + )) + .await?; + let settings = next_thread_settings_updated(&mut server, thread_id).await; + assert_eq!( + ( + settings.thread_settings.model.as_str(), + settings.thread_settings.effort.clone() + ), + ("gpt-5.6-terra", Some(effort.clone())) + ); + if mode == ModeKind::Default && effort == ReasoningEffortConfig::High { + insta::assert_snapshot!( + "session_only_model_confirmation", + next_history_message(&mut events) + ); + } + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadSettingsUpdated(settings), + ) + .await?; + assert_eq!( + ( + app.chat_widget.current_model(), + app.chat_widget.current_reasoning_effort() + ), + ("gpt-5.6-terra", Some(effort)) + ); + assert_eq!(std::fs::read(&config_path)?, original.as_bytes()); + } + let fresh = server.start_thread(&app.config).await?; + assert_eq!( + (fresh.session.model.as_str(), fresh.session.reasoning_effort), + ("gpt-5.5", Some(ReasoningEffortConfig::Medium)) + ); + assert_eq!( + app.config.plan_mode_reasoning_effort, + Some(ReasoningEffortConfig::Low) + ); + server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn model_default_saves_report_server_outcomes_and_target_server_profile() -> Result<()> { + for outcome in ["saved", "overridden", "rejected"] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); + let local_config_path = app.config.codex_home.join("config.toml"); + let local_config_before = std::fs::read(&local_config_path).ok(); + let server_home = tempfile::tempdir()?; + let base_path = server_home.path().join("config.toml"); + let profile_path = server_home.path().join("work.config.toml"); + std::fs::write(&base_path, "# Base configuration stays unchanged.\n")?; + std::fs::write(&profile_path, "")?; + let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + loader_overrides.user_config_path = + Some(AbsolutePathBuf::from_absolute_path(&profile_path)?); + loader_overrides.user_config_profile = Some("work".parse()?); + let overrides = if outcome == "overridden" { + vec![ + ("model".into(), toml::Value::String("gpt-5.6-terra".into())), + ( + "model_reasoning_effort".into(), + toml::Value::String("low".into()), + ), + ( + "plan_mode_reasoning_effort".into(), + toml::Value::String("low".into()), + ), + ("service_tier".into(), toml::Value::String("flex".into())), + ] + } else { + Vec::new() + }; + let config = ConfigBuilder::default() + .codex_home(server_home.path().to_path_buf()) + .cli_overrides(overrides.clone()) + .loader_overrides(loader_overrides.clone()) + .harness_overrides(ConfigOverrides { + cwd: Some(server_home.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let client = crate::start_embedded_app_server( + codex_arg0::Arg0DispatchPaths::default(), + config, + overrides, + loader_overrides, + /*strict_config*/ false, + codex_config::CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + /*state_db*/ None, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ) + .await?; + let mut server = AppServerSession::new( + AppServerClient::InProcess(client), + crate::app_server_session::ThreadParamsMode::Remote, + ); + if outcome == "rejected" { + std::fs::write(&profile_path, "[broken")?; + } + while events.try_recv().is_ok() {} + let mut tui = crate::tui::test_support::make_test_tui()?; + for event in [ + AppEvent::PersistModelSelection { + model: "gpt-5.5".into(), + effort: Some(ReasoningEffortConfig::High), + }, + AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High)), + AppEvent::PersistServiceTierSelection { + service_tier: Some(ServiceTier::Fast.request_value().into()), + }, + AppEvent::ApplyAdvancedReasoning { + model: "gpt-5.5".into(), + effort: ReasoningEffortConfig::Ultra, + }, + ] { + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + let messages = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::>() + .join("\n"); + assert_eq!( + messages.matches("higher-priority").count(), + if outcome == "overridden" { 4 } else { 0 } + ); + assert_eq!( + messages.matches("Failed to save").count(), + if outcome == "rejected" { 4 } else { 0 } + ); + if outcome == "overridden" { + insta::assert_snapshot!("overridden_model_defaults", messages); + } + let persisted = std::fs::read_to_string(&profile_path)?; + if outcome == "rejected" { + assert_eq!(persisted, "[broken"); + assert!(!messages.contains("Model changed")); + assert!(!messages.contains("Service tier set")); + } else { + assert_eq!( + toml::from_str::(&persisted)?, + toml::Value::Table(toml::toml! { + model = "gpt-5.5" + model_reasoning_effort = "medium" + plan_mode_reasoning_effort = "high" + service_tier = "fast" + }) + ); + } + assert_eq!(std::fs::read(&local_config_path).ok(), local_config_before); + assert_eq!( + std::fs::read_to_string(&base_path)?, + "# Base configuration stays unchanged.\n" + ); + server.shutdown().await?; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..a83dd9c085b0838ad8bb35de717d266465dc7828 --- /dev/null +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -0,0 +1,621 @@ +//! Command-center inventory and cached navigation recover independently of active work. + +use super::active_reconnect::drain_history; +use super::disconnect::serve_reconnect_requests; +use super::*; +use crate::app::reconnect::ReconnectPresentation; +use crate::app::reconnect::reconnect; +use crate::app_event::AgentsOverviewThreadRefresh; +use crate::app_event::WindowsSandboxEnableMode; +use crate::app_server_session::ThreadParamsMode; +use codex_app_server_client::AppServerEvent; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test] +async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conversation() +-> Result<()> { + use super::super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; + use codex_app_server_protocol::Thread; + use codex_app_server_protocol::ThreadStatus; + use tokio::net::UnixListener; + + // Losing an optional, previously opened thread must not strand the command center either. + for (previous_thread, changed_child_permissions, overview_initialized) in [ + (None, false, false), + (None, false, true), + (Some(ThreadId::new()), false, true), + (Some(ThreadId::new()), true, true), + ] { + let (mut app, mut events, _) = make_test_app_with_channels().await; + app.keymap.app.open_agents = vec![crate::key_hint::alt(KeyCode::Char('a'))]; + let child = ThreadId::new(); + let child_recap = recap::RecapProgress { + completed_turns: 3, + last_recapped_turn_count: Some(3), + }; + app.config.model = Some("gpt-test".into()); + app.active_thread_id = previous_thread; + app.primary_thread_id = previous_thread; + if let Some(id) = previous_thread { + app.config + .permissions + .set_permission_profile(PermissionProfile::read_only())?; + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + AskForApproval::OnRequest, + )); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + let mut session = test_thread_session(id, app.config.cwd.to_path_buf()); + session.approval_policy = AskForApproval::OnRequest; + session.permission_profile = PermissionProfile::read_only(); + app.primary_session_configured = Some(session.clone()); + app.chat_widget.handle_thread_session(session.clone()); + app.ensure_thread_channel(id) + .store + .lock() + .await + .set_session( + session, + vec![test_turn( + "cached", + TurnStatus::Completed, + vec![ThreadItem::Plan { + id: "cached-plan".into(), + text: "Cached previous conversation".into(), + }], + )], + ); + app.chat_widget + .restore_user_message_to_composer("old draft".into()); + app.store_active_thread_receiver().await; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(app.chat_widget.composer_is_empty()); + app.chat_widget + .restore_user_message_to_composer("latest draft".into()); + } + if previous_thread.is_some() { + let child_session = test_thread_session(child, app.config.cwd.to_path_buf()); + app.ensure_thread_channel(child) + .store + .lock() + .await + .set_session(child_session, Vec::new()); + app.thread_event_channels[&child] + .store + .lock() + .await + .merge_recap_progress(child_recap); + app.upsert_agent_picker_thread( + child, /*agent_nickname*/ None, /*agent_role*/ None, + /*is_closed*/ false, + ); + } + let selected = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?; + let vanished = ThreadId::new(); + let added = ThreadId::new(); + let make_thread = |id: ThreadId, name: &str, status: ThreadStatus| -> Result { + Ok(serde_json::from_value(json!({ + "id": id.to_string(), "sessionId": id.to_string(), "preview": name, "name": name, + "ephemeral": false, "modelProvider": "test-provider", "createdAt": 1, + "updatedAt": chrono::Utc::now().timestamp() - 120, "status": status, + "cwd": app.config.cwd, "cliVersion": "0.0.0", "source": "cli", "turns": [] + }))?) + }; + let child_thread = make_thread(child, "Cached child", ThreadStatus::NotLoaded)?; + let stale = vec![ + make_thread( + selected, + "Selected task", + ThreadStatus::Active { + active_flags: Vec::new(), + }, + )?, + make_thread(vanished, "Vanished task", ThreadStatus::Idle)?, + ]; + let fresh = [ + make_thread(selected, "Selected task", ThreadStatus::Idle)?, + make_thread( + added, + "New task", + ThreadStatus::Active { + active_flags: Vec::new(), + }, + )?, + ]; + let stale_threads = stale + .iter() + .map(|thread| Ok((ThreadId::from_string(&thread.id)?, Some(thread.clone())))) + .collect::>>()?; + app.agents_overview.threads = stale_threads.clone(); + app.agents_overview + .last_messages + .insert(selected, "Pre-disconnect answer".into()); + app.agents_overview.initialized = overview_initialized; + let view = app.agents_overview_view( + stale.clone(), + Some(if previous_thread.is_some() { + vanished + } else { + selected + }), + ); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into(); + app.agents_overview.view_state.lock().unwrap().renaming = true; + let draft = |app: &App| app.agents_overview.view_state.lock().unwrap().input.clone(); + let stale_request = Uuid::new_v4(); + app.agents_overview.request_id = Some(stale_request); + app.agents_overview.refresh_pending = true; + let refresh = tokio::spawn(std::future::pending::<()>()); + app.agents_overview.refresh_task = Some(refresh.abort_handle()); + let directory = tempfile::tempdir()?; + let socket_path = directory.path().join("daemon.sock"); + let listener = UnixListener::bind(&socket_path)?; + let endpoint = crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: codex_utils_absolute_path::AbsolutePathBuf::try_from(socket_path.clone())?, + }; + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }; + let interrupted_setup = previous_thread.is_none() && !overview_initialized; + if interrupted_setup { + let preset = codex_utils_approval_presets::builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + app.windows_sandbox.pending_setup = + Some((WindowsSandboxEnableMode::Elevated, preset, None)); + app.windows_sandbox.setup_started_at = Some(Instant::now()); + } else if previous_thread.is_none() { + app.chat_widget.windows_sandbox_elevated_setup_complete = true; + } + let available = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_available = Arc::clone(&available); + let restored_previous = previous_thread + .map(|id| make_thread(id, "Recovered task", ThreadStatus::NotLoaded)) + .transpose()?; + let server = tokio::spawn(async move { + let mut listener = listener; + let mut methods = Vec::new(); + let mut child_opened = false; + for connection in 0..(2 + usize::from(previous_thread.is_some())) { + let (stream, _) = listener.accept().await?; + let socket = tokio_tungstenite::accept_async(stream).await?; + methods.extend(serve_reconnect_requests(socket, |request| std::future::ready({ + match request.method.as_str() { + "thread/loaded/list" if connection == 0 => None, + "thread/resume" if connection == 1 && previous_thread.is_some() => None, + "thread/resume" if request.params.as_ref().unwrap()["threadId"] != child.to_string() && !server_available.load(std::sync::atomic::Ordering::SeqCst) => Some(json!({"error": {"code": -32600, "message": "thread no longer exists"}})), + "thread/resume" => { + let mut thread = if request.params.as_ref().unwrap()["threadId"] == child.to_string() { + child_opened = true; + child_thread.clone() + } else { + restored_previous.clone().unwrap() + }; + if changed_child_permissions && thread.id != child.to_string() { + thread.turns = vec![test_turn("saved", TurnStatus::InProgress, Vec::new())]; + } + let (approval, sandbox) = if changed_child_permissions && thread.id != child.to_string() { + ("on-request", "readOnly") + } else { + ("never", "dangerFullAccess") + }; + Some(json!({"result": {"thread": thread, "model": "gpt-test", "modelProvider": "test-provider", "cwd": thread.cwd, "approvalPolicy": approval, "approvalsReviewer": "user", "sandbox": {"type": sandbox}, "reasoningEffort": null}})) + } + "turn/start" => { + let params = request.params.as_ref().unwrap(); + assert_eq!(params["approvalPolicy"], "on-request"); + assert_eq!(params["sandboxPolicy"]["type"], if changed_child_permissions { json!(null) } else { json!("readOnly") }); + assert_eq!(params["input"][0]["text"], "latest draft!"); + Some(json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}})) + } + "thread/loaded/list" => Some(json!({"result": {"data": [selected, added], "nextCursor": null}})), + "thread/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), + "thread/turns/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), + "thread/goal/get" => Some(json!({"result": {"goal": null}})), + "thread/read" if request.params.as_ref().unwrap()["threadId"] == child.to_string() => Some(json!({"result": {"thread": child_thread}})), + "thread/read" if previous_thread.is_some_and(|id| request.params.as_ref().unwrap()["threadId"] == id.to_string()) => Some(if changed_child_permissions && child_opened { + let mut thread = restored_previous.clone().unwrap(); + let status = if server_available.load(std::sync::atomic::Ordering::SeqCst) { TurnStatus::InProgress } else { TurnStatus::Completed }; + thread.turns = vec![test_turn("saved", status, Vec::new())]; + json!({"result": {"thread": thread}}) + } else if server_available.load(std::sync::atomic::Ordering::SeqCst) { json!({"result": {"thread": restored_previous}}) } else { json!({"error": {"code": -32600, "message": "thread no longer exists"}}) }), + "thread/read" if request.params.as_ref().unwrap()["threadId"] == vanished.to_string() => Some(json!({"error": {"code": -32600, "message": "thread no longer exists"}})), + "thread/read" => Some(json!({"result": {"thread": fresh.iter().find(|thread| request.params.as_ref().unwrap()["threadId"] == thread.id).unwrap()}})), + method => panic!("unexpected daemon reconnect request: {method}"), + } + })).await?); + if connection == 0 { + drop(listener); + std::fs::remove_file(&socket_path)?; + listener = UnixListener::bind(&socket_path)?; + } + } + Ok::<_, color_eyre::Report>(methods) + }); + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Embedded, + ); + assert!( + session + .thread_loaded_list(ThreadLoadedListParams { + cursor: None, + limit: Some(100) + }) + .await + .is_err() + ); + let disconnected = session.next_event().await.unwrap(); + app.handle_app_server_event(&session, disconnected).await; + assert!(app.reconnect.offline); + assert!(app.agents_overview.last_messages.is_empty()); + assert!(refresh.await.unwrap_err().is_cancelled()); + assert_eq!( + ( + app.agents_overview.request_id, + app.agents_overview.refresh_pending + ), + (None, false) + ); + app.apply_agents_overview_thread_refresh( + &session, + stale_request, + Ok(AgentsOverviewThreadRefresh { + last_messages: HashMap::new(), + threads: HashMap::new(), + recent_seed_complete: false, + }), + ); + assert_eq!(app.agents_overview.visible_thread_ids.len(), 2); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into())) + .await?; + assert_eq!(draft(&app), "Keep this task draft!"); + if previous_thread.is_none() { + assert_snapshot!( + "daemon_command_center_reconnecting", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + app.agents_overview + .view_state + .lock() + .unwrap() + .connection_notice = + Some("Reconnect failed — agent list is stale; relaunch to retry"); + assert_snapshot!( + "daemon_command_center_failed", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + } + let connected = reconnect( + app.app_server_target.clone(), + app.config.clone(), + app.local_settings.clone(), + previous_thread, + /*remote_cwd*/ None, + session.thread_tool_transport(), + ReconnectPresentation::Overview, + ) + .await?; + app.finish_reconnect( + &mut tui, + &mut session, + &mut events, + connected, + CODEX_CLI_VERSION, + ) + .await?; + if interrupted_setup { + assert!(app.windows_sandbox.pending_setup.is_none()); + assert!(app.windows_sandbox.setup_started_at.is_none()); + let mut retained = Vec::new(); + let mut saw_warning = false; + while let Ok(event) = events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = &event { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 100)); + if rendered.contains("Windows sandbox setup was interrupted") { + insta::assert_snapshot!(rendered, @"■ Windows sandbox setup was interrupted. Restart Codex before using Agent mode."); + saw_warning = true; + continue; + } + } + retained.push(event); + } + assert!(saw_warning); + for event in retained { + app.app_event_tx.send(event); + } + } else if previous_thread.is_none() { + assert!(app.chat_widget.windows_sandbox_elevated_setup_complete); + } + assert!(!app.reconnect.offline); + assert_eq!(app.current_displayed_thread_id(), previous_thread); + + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + let mut deferred = Vec::new(); + while app.agents_overview.request_id.is_some() { + let event = events.recv().await.expect("inventory refresh event"); + if matches!(event, AppEvent::AgentsOverviewThreadsLoaded { .. }) { + app.handle_event(&mut tui, &mut session, event).await?; + } else { + deferred.push(event); + } + } + for event in deferred { + app.app_event_tx.send(event); + } + Ok::<_, color_eyre::Report>(()) + }) + .await??; + assert!(app.agents_overview.visible_thread_ids.contains(&vanished)); + + // Missing metadata alone does not evict a retained row; an archive notification does. + app.handle_app_server_event( + &session, + AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadArchived( + ThreadArchivedNotification { + thread_id: vanished.to_string(), + }, + ))), + ) + .await; + let index = app + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .unwrap(); + if previous_thread.is_none() { + assert_eq!(app.agents_overview.visible_thread_ids[index], selected); + } + assert!(!app.agents_overview.visible_thread_ids.contains(&vanished)); + assert!(app.agents_overview.visible_thread_ids.contains(&added)); + assert_eq!( + draft(&app), + if previous_thread.is_some() { + "" + } else { + "Keep this task draft!" + } + ); + if previous_thread.is_some() { + let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + assert!(history.contains("Unsubmitted title: Keep this task draft!")); + assert_snapshot!( + "daemon_command_center_vanished_rename", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::NewAgentsOverviewSession { .. })) + ); + } + assert!( + app.agents_overview + .view_state + .lock() + .unwrap() + .connection_notice + .is_none() + ); + app.apply_agents_overview_thread_refresh( + &session, + stale_request, + Ok(AgentsOverviewThreadRefresh { + last_messages: HashMap::new(), + threads: stale_threads, + recent_seed_complete: true, + }), + ); + assert!(!app.agents_overview.visible_thread_ids.contains(&vanished)); + + if let Some(id) = previous_thread { + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ) + .await?; + + assert!(app.chat_widget.has_active_view()); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ) + .await?; + + assert!(app.chat_widget.has_active_view()); + // Esc stays in the overview. Dismiss the retained view explicitly + // to inspect the unavailable conversation and its cached draft below. + app.agents_overview.view_state.lock().unwrap().completion = + Some(crate::bottom_pane::ViewCompletion::Accepted); + app.chat_widget.handle_key_event(KeyCode::Null.into()); + assert!(!app.chat_widget.has_active_view()); + assert_eq!(app.current_displayed_thread_id(), Some(id)); + let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + assert!(history.contains("Cached previous conversation")); + + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), + ) + .await?; + let preserved_history = + drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + assert_eq!(preserved_history, history); + + let content = &preserved_history[preserved_history + .find("Cached previous conversation") + .unwrap()..]; + assert_snapshot!( + "reconnected_unavailable_conversation", + format!( + "{content}\n{}", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + ); + + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert_eq!(app.chat_widget.composer_text_with_pending(), "latest draft"); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + ); + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into())) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "latest draft!" + ); + app.handle_event(&mut tui, &mut session, AppEvent::OpenAgentsOverview) + .await?; + assert!(app.chat_widget.has_active_view()); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "latest draft!" + ); + available.store( + !changed_child_permissions, + std::sync::atomic::Ordering::SeqCst, + ); + app.select_agent_thread(&mut tui, &mut session, child) + .await?; + assert_eq!(app.recap.progress(), child_recap); + assert_eq!(app.primary_thread_id, Some(id)); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + &PermissionProfile::Disabled + ); + if changed_child_permissions { + app.runtime_approval_policy_override = Some( + RuntimeApprovalPolicyOverride::Explicit(AskForApproval::Never), + ); + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + ); + app.select_agent_thread(&mut tui, &mut session, id).await?; + assert!(app.thread_unavailable(id)); + assert_eq!(app.current_displayed_thread_id(), Some(id)); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .permission_profile(), + &PermissionProfile::read_only() + ); + } + available.store(true, std::sync::atomic::Ordering::SeqCst); + app.handle_event( + &mut tui, + &mut session, + AppEvent::SelectAgentsOverviewThread { thread_id: id }, + ) + .await?; + assert!(!app.thread_unavailable(id)); + assert_eq!( + app.primary_session_configured, + app.thread_event_channels[&id].store.lock().await.session + ); + assert!(!app.agent_navigation.get(&id).unwrap().is_closed); + app.agent_navigation.mark_running(id); + assert!(app.agent_navigation.get(&id).unwrap().is_running); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "latest draft!" + ); + if changed_child_permissions { + assert!(app.chat_widget.is_task_running_for_test()); + // From an empty draft, daemon Ctrl+C offers the existing Cancel task action. + for key in [ + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ] { + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) + .await?; + } + assert!(std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::RunningTaskExit { action: RunningTaskExitAction::CancelTask, thread_id } if thread_id == id))); + app.handle_app_server_event( + &session, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + turn_completed_notification(id, "saved", TurnStatus::Completed), + )), + ) + .await; + app.drain_active_thread_events(&mut tui).await?; + app.chat_widget + .restore_user_message_to_composer("latest draft!".into()); + } + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + while let Ok(event) = events.try_recv() { + if matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })) { + app.handle_event(&mut tui, &mut session, event).await?; + } + } + let channel = app.thread_event_channels.get(&id).unwrap(); + let event = ThreadBufferedEvent::Notification(Box::new( + agent_message_delta_notification(id, "new-turn", "new-item", "fresh delivery"), + )); + channel.sender.send(event).await?; + assert!(app.active_thread_rx.as_mut().unwrap().try_recv().is_ok()); + } + session.shutdown().await?; + let methods = server.await??; + assert_eq!( + methods + .iter() + .filter(|method| *method == "turn/start") + .count(), + usize::from(previous_thread.is_some()) + ); + assert_eq!( + methods.iter().any(|method| method == "thread/resume"), + previous_thread.is_some() + ); + assert_eq!( + methods + .iter() + .filter(|method| *method == "initialize") + .count(), + 2 + usize::from(previous_thread.is_some()) + ); + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/new_session_tests.rs b/codex-rs/tui/src/app/tests/new_session_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..0ee704d9997386a7e848696477bea772565d5f13 --- /dev/null +++ b/codex-rs/tui/src/app/tests/new_session_tests.rs @@ -0,0 +1,312 @@ +//! Replacement starts use server defaults and preserve the current task on failure. + +use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn new_session_preserves_vim_line_yank() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let home = tempdir()?; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(home.path().abs()); + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("saved line"); + for code in [KeyCode::Esc, KeyCode::Char('d'), KeyCode::Char('d')] { + app.chat_widget + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + + let (mut server, _requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget.insert_str("new line"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "new line\nsaved line" + ); + let composer_lines = render_bottom_popup(&app.chat_widget, /*width*/ 80) + .lines() + .take(2) + .collect::>() + .join("\n"); + insta::assert_snapshot!(composer_lines); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn replacement_uses_server_defaults_and_preserves_explicit_launch_settings() -> Result<()> { + for (managed_effort, explicit, expected_model, expected_effort) in [ + (None, "saved", "server-model", "high"), + (Some("medium"), "model", "cli-model", "high"), + (Some("medium"), "effort", "server-model", "low"), + (None, "profile_model", "profile-model", "high"), + (None, "profile_effort", "server-model", "low"), + (Some("medium"), "profile_model", "profile-model", "high"), + (Some("medium"), "profile_effort", "server-model", "low"), + (Some(""), "profile", "profile-model", "low"), + (Some("medium"), "profile", "profile-model", "low"), + ( + Some("medium"), + "profile_unrelated", + "managed-model", + "medium", + ), + ] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let server_home = tempdir()?; + let client_home = tempdir()?; + std::fs::write( + server_home.path().join("work.config.toml"), + "model = \"server-model\"\nmodel_reasoning_effort = \"high\"\n", + )?; + std::fs::write( + client_home.path().join("config.toml"), + "model = \"stale-client-model\"\nmodel_reasoning_effort = \"low\"\n", + )?; + if let Some(effort) = managed_effort { + let effort = if effort.is_empty() { + String::new() + } else { + format!("model_reasoning_effort = {effort:?}\n") + }; + std::fs::write( + server_home.path().join("requirements.toml"), + format!("[models.new_thread]\nmodel = \"managed-model\"\n{effort}"), + )?; + } + let mut server_config = app.config.clone(); + server_config.codex_home = server_home.path().to_path_buf().abs(); + server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs()); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &server_config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + LoaderOverrides { + user_config_path: Some(server_home.path().join("work.config.toml").abs()), + user_config_profile: Some("work".parse()?), + system_requirements_path: Some(server_home.path().join("requirements.toml")), + ..LoaderOverrides::default() + }, + ) + .await?; + server = server.with_remote_cwd_override(Some(server_config.cwd.to_path_buf())); + server.bootstrap(&server_config).await?; + app.config.codex_home = client_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(client_home.path().abs()); + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + ThreadId::new(), + client_home.path().to_path_buf(), + )); + match explicit { + "model" => app.harness_overrides.model = Some("cli-model".to_string()), + "effort" => app.cli_kv_overrides.push(( + "model_reasoning_effort".to_string(), + TomlValue::String("low".to_string()), + )), + profile @ ("profile" | "profile_model" | "profile_effort" | "profile_unrelated") => { + let path = client_home.path().join("work.config.toml"); + std::fs::write( + &path, + match profile { + "profile_model" => "model = \"profile-model\"\n", + "profile_effort" => "model_reasoning_effort = \"low\"\n", + "profile_unrelated" => "model_verbosity = \"low\"\n", + _ => "model = \"profile-model\"\nmodel_reasoning_effort = \"low\"\n", + }, + )?; + app.loader_overrides.user_config_path = Some(path.abs()); + app.loader_overrides.user_config_profile = Some("work".parse()?); + } + _ => {} + } + let mut tui = crate::tui::test_support::make_test_tui()?; + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1); + assert_eq!( + ( + &starts[0]["model"], + &starts[0]["config"]["model_reasoning_effort"] + ), + ( + &serde_json::json!(expected_model), + &serde_json::json!(expected_effort) + ), + ); + assert_eq!( + recorded_params(&requests, "config/read"), + vec![serde_json::json!({"cwd": server_config.cwd.display().to_string()})], + ); + if explicit == "saved" { + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80) + .replace(&server_config.cwd.display().to_string(), ""); + insta::assert_snapshot!(rendered, @r" + › Ask Codex to do anything + + server-model high · + "); + } + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn replacement_failure_keeps_current_task_and_restores_input() -> Result<()> { + for fail_read in [false, true] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let home = tempdir()?; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(home.path().abs()); + let current = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(current, app.config.cwd.to_path_buf()), + Vec::new(), + ) + .await?; + while events.try_recv().is_ok() {} + let original_config = app.config.clone(); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::ThreadStartFails, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + if fail_read { + std::fs::write(home.path().join("config.toml"), "invalid = [")?; + } + let mut tui = crate::tui::test_support::make_test_tui()?; + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut server, + /*session_start_source*/ None, + Some("keep this request".into()), + /*new_thread_name*/ None, + ) + .await; + assert_eq!(app.chat_widget.thread_id(), Some(current)); + assert_eq!(app.active_thread_id, Some(current)); + assert!(app.active_thread_rx.is_some()); + assert_eq!(app.config, original_config); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "keep this request" + ); + assert!(recorded_params(&requests, "thread/unsubscribe").is_empty()); + assert_eq!( + recorded_params(&requests, "thread/start").len(), + usize::from(!fail_read) + ); + let errors = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 1000))) + } + _ => None, + }) + .collect::>() + .join("\n"); + if fail_read { + insta::assert_snapshot!(errors, @"■ Failed to read new session defaults: config/read failed in TUI"); + } else { + insta::assert_snapshot!(errors, @"■ Failed to start a fresh session through the app server: thread/start failed during TUI bootstrap: thread/start failed: replacement unavailable (code -32603)"); + } + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn replacement_preserves_remote_launch_paths_and_older_servers() -> Result<()> { + for (remote_cwd, capabilities) in [ + (None, HistoryCapabilities::Current), + (Some(PathBuf::from(".")), HistoryCapabilities::Current), + (None, HistoryCapabilities::ConfigReadUnsupported(-32601)), + (None, HistoryCapabilities::ConfigReadUnsupported(-32600)), + ] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let home = tempdir()?; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(home.path().abs()); + std::fs::write( + home.path().join("config.toml"), + "model = \"configured-model\"\n", + )?; + let (server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + capabilities, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + LoaderOverrides::default(), + ) + .await?; + let mut server = server.with_remote_cwd_override(remote_cwd.clone()); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + assert_eq!( + recorded_params(&requests, "config/read"), + vec![serde_json::json!({"cwd": "."}),] + ); + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1); + assert_eq!( + (&starts[0]["cwd"], &starts[0]["model"]), + ( + &serde_json::json!(remote_cwd), + &serde_json::json!("configured-model") + ), + ); + assert_eq!(app.chat_widget.current_model(), "configured-model"); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..54bd23ba1f94219f2c71f3193f4c0492a0c05463 --- /dev/null +++ b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs @@ -0,0 +1,170 @@ +//! Completed older pages keep each footer after its answer, including overlapping pages. + +use super::session_lifecycle_requests::start_recording_app_server; +use super::*; +use app_test_support::create_fake_paginated_rollout; +use app_test_support::rollout_path; +use chrono::TimeZone; +use codex_app_server_protocol::ThreadItemsListResponse; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_state::SqliteConfig; + +#[tokio::test] +async fn older_pagination_completion_footers_follow_answers_without_overlap_duplicates() +-> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(2); + let completed_at = chrono::Local + .with_ymd_and_hms( + /*year*/ 2000, /*month*/ 9, /*day*/ 6, /*hour*/ 14, + /*min*/ 32, /*sec*/ 0, + ) + .single() + .expect("unambiguous local completion time"); + let timestamp = completed_at.to_rfc3339(); + let filename_timestamp = "2000-09-06T14-32-00"; + let thread_id = create_fake_paginated_rollout( + codex_home.path(), + filename_timestamp, + ×tamp, + "completed pagination", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .map_err(|error| color_eyre::eyre::eyre!(error))?; + let path = rollout_path(codex_home.path(), filename_timestamp, &thread_id); + let thread_id = ThreadId::from_string(&thread_id)?; + let mut records = std::fs::read_to_string(&path)? + .lines() + .take(/*n*/ 1) + .map(serde_json::from_str::) + .collect::, _>>()?; + for (index, name) in ["Oldest", "Middle", "Newest"].into_iter().enumerate() { + let turn_id = format!("turn-{index}"); + let finished = completed_at.timestamp() + index as i64 * 60; + let mut events = vec![EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.clone(), + root_turn_id: None, + trace_id: None, + started_at: Some(finished - 125), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })]; + let items = [ + TurnItem::UserMessage(UserMessageItem { + id: format!("prompt-{index}"), + client_id: None, + content: vec![CoreUserInput::Text { + text: format!("{name} prompt"), + text_elements: Vec::new(), + }], + }), + TurnItem::AgentMessage(AgentMessageItem { + id: format!("answer-{index}"), + content: vec![AgentMessageContent::Text { + text: format!("{name} answer"), + }], + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }), + ]; + events.extend(items.into_iter().map(|item| { + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: turn_id.clone(), + item, + started_at_ms: None, + completed_at_ms: finished * 1_000, + }) + })); + events.push(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id, + last_agent_message: Some(format!("{name} answer")), + error: None, + started_at: Some(finished - 125), + completed_at: Some(finished), + duration_ms: Some(125_000), + time_to_first_token_ms: None, + })); + for event in events { + records.push(serde_json::json!({ + "timestamp": timestamp, + "ordinal": records.len(), + "type": "event_msg", + "payload": event, + })); + } + } + let records = records + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + std::fs::write(path, format!("{records}\n"))?; + let (mut app_server, _requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let started = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + // Snapshot only the prepended pages; the newest turn is already hydrated in the store. + app.local_settings.tui.terminal_resize_reflow_max_rows = None; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.open_transcript_overlay(&mut tui); + let mut overlapping_answer = None; + let mut snapshots = Vec::new(); + for limit in [1, 100] { + let cursor = app_server + .begin_older_history_page(thread_id) + .expect("older page"); + let mut page: ThreadItemsListResponse = app_server + .thread_items_page( + thread_id, + /*turn_id*/ None, + Some(cursor.clone()), + limit, + ) + .await?; + if let Some(answer) = overlapping_answer.take() { + // The later response repeats the preceding page's completed answer. + page.data.insert(/*index*/ 0, answer); + } else { + overlapping_answer = page.data.first().cloned(); + } + app.handle_older_history_page(&mut tui, &mut app_server, thread_id, &cursor, Ok(page)) + .await?; + snapshots.push( + app.render_transcript_lines_for_reflow(/*width*/ 80) + .lines + .iter() + .map(rendered_line_text) + .collect::>() + .join("\n"), + ); + } + insta::assert_snapshot!(snapshots.join("\n\n--- next overlapping page ---\n\n")); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/patch_approval_tests.rs b/codex-rs/tui/src/app/tests/patch_approval_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..8f5e36e1012ee854c57b4e6f60651aabcdddb6df --- /dev/null +++ b/codex-rs/tui/src/app/tests/patch_approval_tests.rs @@ -0,0 +1,318 @@ +//! Regression coverage for the live and replayed patch-approval pager. + +use super::*; +use crate::app::app_server_requests::AppServerRequestResolution; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::PatchApplyStatus; +use pretty_assertions::assert_eq; +use ratatui::layout::Rect; +use tokio::sync::mpsc::UnboundedReceiver; + +const TURN_ID: &str = "turn-patch-approval"; +const ITEM_ID: &str = "exec-patch-approval"; + +fn changes() -> Vec { + vec![ + FileUpdateChange { + path: "a-added.txt".to_string(), + kind: PatchChangeKind::Add, + diff: "alpha\nbeta\n".to_string(), + }, + FileUpdateChange { + path: "m-updated.txt".to_string(), + kind: PatchChangeKind::Update { move_path: None }, + diff: "@@ -1 +1 @@\n-old\n+new\n".to_string(), + }, + FileUpdateChange { + path: "z-deleted.txt".to_string(), + kind: PatchChangeKind::Delete, + diff: "removed\n".to_string(), + }, + ] +} + +fn expected_changes() -> HashMap { + HashMap::from([ + ( + PathBuf::from("a-added.txt"), + FileChange::Add { + content: "alpha\nbeta\n".to_string(), + }, + ), + ( + PathBuf::from("m-updated.txt"), + FileChange::Update { + unified_diff: "@@ -1 +1 @@\n-old\n+new\n".to_string(), + move_path: None, + }, + ), + ( + PathBuf::from("z-deleted.txt"), + FileChange::Delete { + content: "removed\n".to_string(), + }, + ), + ]) +} + +fn patch_item() -> ThreadItem { + ThreadItem::FileChange { + id: ITEM_ID.to_string(), + changes: changes(), + status: PatchApplyStatus::InProgress, + } +} + +fn request(thread_id: ThreadId) -> ServerRequest { + ServerRequest::FileChangeRequestApproval { + request_id: AppServerRequestId::Integer(17), + params: FileChangeRequestApprovalParams { + thread_id: thread_id.to_string(), + turn_id: TURN_ID.to_string(), + item_id: ITEM_ID.to_string(), + started_at_ms: 0, + reason: None, + grant_root: None, + }, + } +} + +async fn enqueue_pending_patch(app: &mut App, thread_id: ThreadId) -> Result<()> { + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + app.enqueue_primary_thread_session(test_thread_session(thread_id, cwd), Vec::new()) + .await?; + app.enqueue_thread_notification( + thread_id, + ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: thread_id.to_string(), + turn_id: TURN_ID.to_string(), + started_at_ms: 0, + item: patch_item(), + }), + ) + .await?; + let request = request(thread_id); + assert_eq!( + app.pending_app_server_requests + .note_server_request(&request), + None + ); + app.enqueue_thread_request(thread_id, request).await +} + +async fn drain_pending_patch(app: &mut App, tui: &mut crate::tui::Tui) -> Result<()> { + app.drain_active_thread_events_until(tui, Instant::now() + Duration::from_secs(/*secs*/ 1)) + .await +} + +fn open_patch(app: &mut App, rx: &mut UnboundedReceiver) -> ApplyPatchApprovalRequest { + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + let mut fullscreen = None; + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::FullScreenApprovalRequest(ApprovalRequest::ApplyPatch(request)) => { + assert!(fullscreen.replace(request).is_none()); + } + AppEvent::SubmitThreadOp { .. } => panic!("opening the pager must not decide approval"), + _ => {} + } + } + fullscreen.expect("Ctrl+A should open the pending patch") +} + +fn render_pager(app: &mut App, width: u16, height: u16) -> Buffer { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buffer = Buffer::empty(area); + let Some(Overlay::Static(overlay)) = app.overlay.as_mut() else { + panic!("expected the patch pager"); + }; + overlay.render(area, &mut buffer); + buffer +} + +fn assert_decision( + app: &mut App, + rx: &mut UnboundedReceiver, + thread_id: ThreadId, + decision: FileChangeApprovalDecision, +) { + let mut submitted = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { thread_id, op } = event { + submitted.push((thread_id, op)); + } + } + let expected_op = AppCommand::patch_approval(ITEM_ID.to_string(), decision.clone()); + assert_eq!(submitted, vec![(thread_id, expected_op.clone())]); + let thread_id = thread_id.to_string(); + assert_eq!( + app.pending_app_server_requests + .take_resolution(&thread_id, expected_op.clone()), + Ok(Some(AppServerRequestResolution { + request_id: AppServerRequestId::Integer(17), + result: serde_json::json!({ "decision": decision }), + })) + ); + assert_eq!( + app.pending_app_server_requests + .take_resolution(&thread_id, expected_op), + Ok(None) + ); +} + +#[tokio::test] +async fn active_patch_approval_pager_preserves_changes_and_accepts_once() -> Result<()> { + let (mut app, mut rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = start_config_write_test_app_server(&app).await?; + enqueue_pending_patch(&mut app, thread_id).await?; + drain_pending_patch(&mut app, &mut tui).await?; + + let request = open_patch(&mut app, &mut rx); + assert_eq!(request.changes, expected_changes()); + assert_eq!(request.id, ITEM_ID); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::FullScreenApprovalRequest(ApprovalRequest::ApplyPatch(request)), + ) + .await?; + let first = render_pager(&mut app, /*width*/ 48, /*height*/ 11); + insta::with_settings!({snapshot_path => "../../snapshots"}, { + insta::assert_snapshot!("patch_approval_pager_top", format!("{first:?}")); + }); + + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)), + ) + .await?; + let last = render_pager(&mut app, /*width*/ 48, /*height*/ 11); + insta::with_settings!({snapshot_path => "../../snapshots"}, { + insta::assert_snapshot!("patch_approval_pager_bottom", format!("{last:?}")); + }); + let _ = render_pager(&mut app, /*width*/ 36, /*height*/ 7); + assert_eq!(render_pager(&mut app, /*width*/ 48, /*height*/ 11), last); + + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + ) + .await?; + assert!(app.overlay.is_none()); + assert!( + app.pending_app_server_requests + .contains_server_request(&self::request(thread_id)) + ); + assert_eq!(open_patch(&mut app, &mut rx).changes, expected_changes()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_decision( + &mut app, + &mut rx, + thread_id, + FileChangeApprovalDecision::Accept, + ); + Ok(()) +} + +#[tokio::test] +async fn active_patch_approval_cancel_and_resolved_replay() -> Result<()> { + let (mut app, mut rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let mut tui = crate::tui::test_support::make_test_tui()?; + enqueue_pending_patch(&mut app, thread_id).await?; + drain_pending_patch(&mut app, &mut tui).await?; + assert_eq!(open_patch(&mut app, &mut rx).changes, expected_changes()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_decision( + &mut app, + &mut rx, + thread_id, + FileChangeApprovalDecision::Cancel, + ); + + app.enqueue_thread_request(thread_id, request(thread_id)) + .await?; + drain_pending_patch(&mut app, &mut tui).await?; + assert!(!app.chat_widget.has_active_view()); + assert!(rx.try_recv().is_err()); + Ok(()) +} + +#[tokio::test] +async fn active_patch_approval_preserves_deferred_startup_protection() -> Result<()> { + let (mut app, mut rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let mut tui = crate::tui::test_support::make_test_tui()?; + enqueue_pending_patch(&mut app, thread_id).await?; + app.startup_protected_input_boundary = true; + app.chat_widget.handle_server_notification( + agent_message_delta_notification(thread_id, TURN_ID, "agent-1", "streaming"), + /*replay_kind*/ None, + ); + drain_pending_patch(&mut app, &mut tui).await?; + assert!(!app.chat_widget.has_active_view()); + assert!(app.startup_pending_protected_request); + + app.chat_widget.handle_server_notification( + ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: TURN_ID.to_string(), + completed_at_ms: 0, + item: ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "streaming".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + }), + /*replay_kind*/ None, + ); + assert_eq!(open_patch(&mut app, &mut rx).changes, expected_changes()); + Ok(()) +} + +#[tokio::test] +async fn replayed_patch_approval_pager_recovers_stored_turn_changes() { + let (mut app, mut rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + app.replay_thread_snapshot( + ThreadEventSnapshot { + delegated_turns: Vec::new(), + session: Some(test_thread_session(thread_id, cwd)), + turns: vec![test_turn( + TURN_ID, + TurnStatus::InProgress, + vec![patch_item()], + )], + events: vec![ThreadBufferedEvent::Request(Box::new(request(thread_id)))], + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + assert_eq!(open_patch(&mut app, &mut rx).changes, expected_changes()); +} + +#[test] +fn patch_approval_lookup_keeps_turn_and_item_identity() { + let mut store = ThreadEventStore::new(/*capacity*/ 2); + store.set_turns(vec![test_turn( + TURN_ID, + TurnStatus::InProgress, + vec![patch_item()], + )]); + assert_eq!(store.file_change_changes(TURN_ID, ITEM_ID), Some(changes())); + assert_eq!(store.file_change_changes("", ITEM_ID), Some(changes())); + assert_eq!(store.file_change_changes("another-turn", ITEM_ID), None); + assert_eq!(store.file_change_changes(TURN_ID, "another-item"), None); +} diff --git a/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs b/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..dacdff505870cf8bce42d59b28ab15ca38ecb47f --- /dev/null +++ b/codex-rs/tui/src/app/tests/permission_shortcuts_tests.rs @@ -0,0 +1,154 @@ +use super::*; +use codex_arg0::Arg0DispatchPaths; +use pretty_assertions::assert_eq; + +fn read_only_selection() -> PermissionProfileSelection { + PermissionProfileSelection { + profile_id: ":read-only".to_string(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".to_string(), + } +} + +#[tokio::test] +async fn permission_shortcut_rejections_leave_state_unchanged() -> Result<()> { + for experimental_api in [false, true] { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + thread_id, + app.config.cwd.to_path_buf(), + )); + let original = RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()); + let original_reviewer = app.config.approvals_reviewer; + let client = crate::start_embedded_app_server_with( + Arg0DispatchPaths::default(), + app.config.clone(), + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + /*strict_config*/ false, + CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + /*state_db*/ None, + Arc::clone(&app.environment_manager), + |mut args| { + args.experimental_api = experimental_api; + codex_app_server_client::InProcessAppServerClient::start(args) + }, + ) + .await?; + let mut app_server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess(client), + crate::app_server_session::ThreadParamsMode::Embedded, + ); + while events.try_recv().is_ok() {} + let transcript_len = app.transcript_cells.len(); + app.apply_permission_shortcut( + &mut app_server, + &mut tui, + ThreadId::new(), + read_only_selection(), + ) + .await; + assert_eq!(app.transcript_cells.len(), transcript_len); + assert!(events.try_recv().is_err()); + app.apply_permission_shortcut(&mut app_server, &mut tui, thread_id, read_only_selection()) + .await; + assert_eq!( + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), + original + ); + assert_eq!(app.config.approvals_reviewer, original_reviewer); + let cell = app.transcript_cells.last().expect("rejection notice"); + insta::assert_snapshot!( + if experimental_api { + "permission_shortcut_server_error" + } else { + "permission_shortcut_unsupported" + }, + lines_to_single_string(&cell.display_lines(/*width*/ 120)) + .replace(&thread_id.to_string(), "") + ); + assert!(events.try_recv().is_err()); + if !experimental_api { + app.select_permission_profile( + &mut app_server, + PermissionProfileSelection { + profile_id: "server-only".into(), + approval_policy: None, + approvals_reviewer: None, + display_label: "server-only".into(), + }, + ) + .await; + insta::assert_snapshot!( + next_history_message(&mut events), + @"■ Named profiles require a newer app server." + ); + } + app_server.shutdown().await?; + } + Ok(()) +} + +#[tokio::test] +async fn permission_shortcut_confirms_without_persisting() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(":workspace"), + ))?; + let config_path = codex_home.path().join("config.toml"); + let contents = "approvals_reviewer = \"auto_review\"\n"; + std::fs::write(&config_path, contents)?; + let mut app_server = start_config_write_test_app_server(&app).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.chat_widget + .handle_thread_session_quiet(started.session.clone()); + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + let contents = std::fs::read_to_string(&config_path)?; + while events.try_recv().is_ok() {} + + app.apply_permission_shortcut(&mut app_server, &mut tui, thread_id, read_only_selection()) + .await; + + let cell = app.transcript_cells.last().expect("confirmed notice"); + insta::assert_snapshot!( + lines_to_single_string(&cell.display_lines(/*width*/ 80)), + @"• Permissions updated to Read Only" + ); + + let settings = next_thread_settings_updated(&mut app_server, thread_id) + .await + .thread_settings; + let profile = app + .chat_widget + .config_ref() + .permissions + .active_permission_profile(); + assert_eq!( + settings.active_permission_profile, + profile.clone().map(Into::into) + ); + assert_eq!(profile, Some(ActivePermissionProfile::new(":read-only"))); + assert_eq!(app.config.approvals_reviewer, ApprovalsReviewer::User); + assert_eq!(std::fs::read_to_string(config_path)?, contents); + assert!( + events.try_recv().is_err(), + "must not queue another update or config write" + ); + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/plugin_catalog.rs b/codex-rs/tui/src/app/tests/plugin_catalog.rs new file mode 100644 index 0000000000000000000000000000000000000000..251b8817bd3902f11483c82d95c63b13783c86db --- /dev/null +++ b/codex-rs/tui/src/app/tests/plugin_catalog.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use codex_app_server_protocol::PluginUninstallResponse; + +use super::*; + +#[tokio::test] +async fn successful_plugin_uninstall_dispatches_plugin_list_refresh() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + while app_event_rx.try_recv().is_ok() {} + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::PluginUninstallLoaded { + cwd: cwd.clone(), + plugin_id: "plugin-docs".to_string(), + plugin_display_name: "Docs".to_string(), + result: Ok(PluginUninstallResponse {}), + }, + )) + .await?; + assert!(matches!(control, AppRunControl::Continue)); + + let refresh_result = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match app_event_rx.recv().await { + Some(AppEvent::PluginsLoaded { + cwd: event_cwd, + result, + }) if event_cwd == cwd => break result, + Some(_) => {} + None => panic!("app event channel closed before plugin refresh completed"), + } + } + }) + .await + .expect("dispatcher should initiate a plugin list refresh"); + refresh_result.expect("plugin list refresh should succeed"); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/rate_limits.rs b/codex-rs/tui/src/app/tests/rate_limits.rs new file mode 100644 index 0000000000000000000000000000000000000000..344c703aea35445ed9d7ecf8c6335ffb04baf075 --- /dev/null +++ b/codex-rs/tui/src/app/tests/rate_limits.rs @@ -0,0 +1,555 @@ +use super::*; +use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CreditsSnapshot; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::RateLimitReachedType; +use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use pretty_assertions::assert_eq; + +fn rate_limit_snapshot( + used_percent: i32, + rate_limit_reached_type: Option, + spend_control_reached: Option, +) -> RateLimitSnapshot { + RateLimitSnapshot { + limit_id: Some("codex".to_string()), + limit_name: None, + normal_model_slug: None, + primary: Some(RateLimitWindow { + used_percent, + window_duration_mins: Some(300), + resets_at: None, + }), + secondary: None, + credits: Some(CreditsSnapshot { + has_credits: true, + unlimited: false, + balance: None, + }), + individual_limit: None, + spend_control_reached, + plan_type: None, + rate_limit_reached_type, + } +} + +fn account_rate_limits_response(snapshot: RateLimitSnapshot) -> GetAccountRateLimitsResponse { + GetAccountRateLimitsResponse { + ordinary_usage_allowed: None, + account_id: None, + rate_limit_upsell: None, + rate_limits: snapshot, + rate_limits_by_limit_id: None, + rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { + available_count: 0, + credits: None, + }), + } +} + +async fn deliver_rolling_rate_limit_snapshot( + app: &mut App, + app_server: &AppServerSession, + snapshot: RateLimitSnapshot, +) { + app.handle_app_server_event( + app_server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::AccountRateLimitsUpdated(AccountRateLimitsUpdatedNotification { + rate_limits: snapshot, + }), + )), + ) + .await; +} + +fn render_status_output( + app: &mut App, + app_event_rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> String { + while app_event_rx.try_recv().is_ok() {} + app.chat_widget.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell + .display_lines(/*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + other => panic!("expected status output, got {other:?}"), + } +} + +fn deliver_usage_limit_error(app: &mut App) { + app.chat_widget.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + misalignment: None, + message: "Usage limit reached.".to_string(), + codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded), + additional_details: None, + }, + will_retry: false, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }), + /*replay_kind*/ None, + ); +} + +#[tokio::test] +async fn backend_banner_state_survives_widget_replacement() -> Result<()> { + for dismiss in [false, true] { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let mut response = account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 100, /*rate_limit_reached_type*/ None, + /*spend_control_reached*/ None, + )); + response.rate_limit_upsell = Some(serde_json::json!({ + "banner_type": "plus_rate_limit_reached", "title": "Usage limit reached", + "presentation": "dismissible", + "description": "Choose how to continue.", + "ctas": [{"action": "view_usage", "label": "View usage"}] + })); + app.chat_widget.update_backend_banner(&response); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage")); + if dismiss { + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + let before = render_bottom_popup(&app.chat_widget, /*width*/ 90); + let mut tui = crate::tui::test_support::make_test_tui()?; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + app.config.clone(), + /*initial_user_message*/ None, + ); + app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + set_active_cell( + &mut app.chat_widget, + Box::new(PlainHistoryCell::new(Vec::new())), + ); + app.chat_widget.pre_draw_tick(); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before); + app.chat_widget.update_backend_banner(&response); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before); + response.rate_limit_upsell = None; + app.chat_widget.update_backend_banner(&response); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("View usage")); + } + Ok(()) +} + +#[tokio::test] +async fn rolling_workspace_hard_stops_invalidate_older_rate_limit_reads() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + + let cases = [ + (None, None, false), + (Some(RateLimitReachedType::RateLimitReached), None, false), + (None, Some(false), false), + (None, Some(true), true), + ( + Some(RateLimitReachedType::WorkspaceOwnerCreditsDepleted), + None, + true, + ), + ( + Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted), + None, + true, + ), + ( + Some(RateLimitReachedType::WorkspaceOwnerUsageLimitReached), + None, + true, + ), + ( + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + None, + true, + ), + ]; + let mut expected_generation = 0; + for (reached_type, spend_control_reached, invalidates) in cases { + deliver_rolling_rate_limit_snapshot( + &mut app, + &app_server, + rate_limit_snapshot( + /*used_percent*/ 95, + reached_type, + spend_control_reached, + ), + ) + .await; + if invalidates { + expected_generation += 1; + } + assert_eq!( + app.rate_limit_hard_stop_generation, expected_generation, + "reached_type={reached_type:?}, spend_control_reached={spend_control_reached:?}" + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_origin() -> Result<()> +{ + for origin_name in [ + "startup", + "status", + "usage", + "reset-picker", + "reset-consume", + ] { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + + app.chat_widget + .on_rate_limit_snapshot(Some(rate_limit_snapshot( + /*used_percent*/ 20, + /*rate_limit_reached_type*/ None, + Some(false), + ))); + let origin = match origin_name { + "startup" => RateLimitRefreshOrigin::StartupPrefetch { + reset_hint_request_id: app.chat_widget.start_rate_limit_reset_startup_check(), + }, + "status" => { + let request_id = 7; + app.chat_widget + .add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + RateLimitRefreshOrigin::StatusCommand { request_id } + } + "usage" => { + let startup_request_id = app.chat_widget.start_rate_limit_reset_startup_check(); + app.chat_widget.finish_rate_limit_reset_hint_refresh( + startup_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None, + }), + ); + app.chat_widget.insert_str("/usage"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + loop { + match app_event_rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { origin }) => break origin, + Ok(_) => {} + other => panic!("expected usage refresh request, got {other:?}"), + } + } + } + "reset-picker" => RateLimitRefreshOrigin::ResetPicker { + request_id: app.chat_widget.show_rate_limit_reset_loading_popup(), + }, + "reset-consume" => RateLimitRefreshOrigin::ResetConsume { + request_id: app.chat_widget.show_rate_limit_reset_consuming_popup(), + }, + _ => unreachable!("unknown refresh origin"), + }; + let read_generation = app.rate_limit_hard_stop_generation; + let mut rolling_snapshot = rate_limit_snapshot( + /*used_percent*/ 95, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + Some(true), + ); + if origin_name == "reset-picker" { + rolling_snapshot.limit_id = Some("codex_other".to_string()); + } + deliver_rolling_rate_limit_snapshot(&mut app, &app_server, rolling_snapshot).await; + assert_ne!(read_generation, app.rate_limit_hard_stop_generation); + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RateLimitsLoaded { + request_id: 0, + origin, + hard_stop_generation: read_generation, + result: Ok(account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 0, + /*rate_limit_reached_type*/ None, + Some(false), + ))), + }, + )) + .await?; + assert!(matches!(control, AppRunControl::Continue)); + + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + match origin_name { + "usage" => assert!(popup.contains("No usage limit resets available.")), + "reset-picker" => { + assert!(popup.contains("You don't have any usage limit resets available.")); + } + "reset-consume" => { + assert!(popup.contains("Usage reset. You have 0 usage limit resets left.")); + } + "startup" | "status" => {} + _ => unreachable!("unknown refresh origin"), + } + + let status = render_status_output(&mut app, &mut app_event_rx); + assert!( + status.contains("80% left"), + "expected {origin_name} to preserve the last account usage snapshot, got: {status}" + ); + deliver_usage_limit_error(&mut app); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + popup.contains("Request a limit increase from your owner"), + "expected {origin_name} to preserve workspace error routing, got: {popup}" + ); + + app_server.shutdown().await?; + } + + Ok(()) +} + +#[tokio::test] +async fn stale_rate_limit_read_does_not_dismiss_visible_workspace_advisory() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let request_id = 7; + app.chat_widget + .add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + let read_generation = app.rate_limit_hard_stop_generation; + + deliver_rolling_rate_limit_snapshot( + &mut app, + &app_server, + rate_limit_snapshot( + /*used_percent*/ 95, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + Some(true), + ), + ) + .await; + app.chat_widget.handle_server_notification( + turn_completed_notification(ThreadId::new(), "turn-1", TurnStatus::Completed), + /*replay_kind*/ None, + ); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Approaching rate limits") + ); + + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RateLimitsLoaded { + request_id: 0, + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + hard_stop_generation: read_generation, + result: Ok(account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 0, + /*rate_limit_reached_type*/ None, + Some(false), + ))), + }, + )) + .await?; + + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Approaching rate limits") + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn post_hard_stop_rate_limit_read_clears_recovered_workspace_limit() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + set_chatgpt_auth(&mut app.chat_widget); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + deliver_rolling_rate_limit_snapshot( + &mut app, + &app_server, + rate_limit_snapshot( + /*used_percent*/ 95, + Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached), + Some(true), + ), + ) + .await; + let read_generation = app.rate_limit_hard_stop_generation; + let request_id = 7; + app.chat_widget + .add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RateLimitsLoaded { + request_id: 0, + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + hard_stop_generation: read_generation, + result: Ok(account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 0, + /*rate_limit_reached_type*/ None, + Some(false), + ))), + }, + )) + .await?; + assert!(matches!(control, AppRunControl::Continue)); + + let status = render_status_output(&mut app, &mut app_event_rx); + assert!( + status.contains("100% left"), + "expected recovered limits, got: {status}" + ); + deliver_usage_limit_error(&mut app); + let popup = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!( + !popup.contains("Request a limit increase from your owner"), + "expected recovered state to clear workspace error routing, got: {popup}" + ); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn failed_rate_limit_read_preserves_visible_backend_banner() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let mut response = account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 25, + /*rate_limit_reached_type*/ None, + Some(false), + )); + response.account_id = Some("workspace-a".into()); + response.rate_limit_upsell = Some(serde_json::json!({ + "banner_type": "workspace_recovery", "presentation": "inline", + "title": "Workspace needs credits", "description": "Ask your owner for credits.", + "ctas": [] + })); + app.chat_widget.update_backend_banner(&response); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 90); + assert!(before.contains("Workspace needs credits")); + let request_id = 7; + app.chat_widget + .add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); + let generation = app.rate_limit_hard_stop_generation; + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::RateLimitsLoaded { + request_id: 0, + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + hard_stop_generation: generation, + result: Err("transient test failure".into()), + }, + ) + .await?; + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 90), before); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn backend_banner_reads_ignore_older_completions() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let mut session = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.set_model("test-model-a"); + let mut current = account_rate_limits_response(rate_limit_snapshot( + /*used_percent*/ 100, /*rate_limit_reached_type*/ None, + /*spend_control_reached*/ None, + )); + current.rate_limit_upsell = Some(serde_json::json!({ + "banner_type":"selected_model_limit", "model_slug":"test-model-a", "presentation":"inline", + "title":"Selected model usage exhausted", "description":"Contact your owner.", "ctas":[] + })); + for request_id in 1..=5 { + app.rate_limit_refresh_state + .start( + RateLimitRefreshOrigin::StatusCommand { request_id }, + &mut app.rate_limit_hard_stop_generation, + ) + .unwrap(); + } + for (id, generation, result, expect_visible) in [ + (2, 0, Ok(current.clone()), true), + ( + 1, + 0, + Ok({ + let mut absent = current.clone(); + absent.rate_limit_upsell = None; + absent + }), + true, + ), + (4, 0, Err("transient failure".into()), true), + ( + 3, + 0, + Ok({ + let mut absent = current.clone(); + absent.rate_limit_upsell = None; + absent + }), + false, + ), + (5, 0, Ok(current.clone()), true), + ] { + app.handle_event( + &mut tui, + &mut session, + AppEvent::RateLimitsLoaded { + request_id: id, + origin: RateLimitRefreshOrigin::StatusCommand { request_id: id }, + hard_stop_generation: generation, + result, + }, + ) + .await?; + assert_eq!( + render_bottom_popup(&app.chat_widget, /*width*/ 90) + .contains("Selected model usage exhausted"), + expect_visible + ); + } + session.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs b/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs new file mode 100644 index 0000000000000000000000000000000000000000..a7a0aeec7eb56195450452669fb8c2c76bd52d5d --- /dev/null +++ b/codex-rs/tui/src/app/tests/realtime_handoff_e2e.rs @@ -0,0 +1,275 @@ +//! Real Core-to-app-server-to-TUI handoff coverage without an audio device. + +use super::*; +use crate::chatwidget::UserMessage; +use app_test_support::MockResponsesConfig; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ServerNotification; +use codex_login::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; +use codex_model_provider_info::ModelProviderInfo; +use core_test_support::responses; +use core_test_support::responses::WebSocketConnectionConfig; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use serde_json::json; +use std::time::Duration; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn delegated_core_events_keep_private_output_hidden_and_deliver_final_speech() -> Result<()> { + core_test_support::skip_if_no_network!(Ok(())); + + let mut commentary = + responses::ev_assistant_message("private-commentary", "PRIVATE COMMENTARY"); + commentary["item"]["phase"] = json!("commentary"); + let mut final_answer = responses::ev_assistant_message( + "public-final", + "[ANALYSIS] is the marker you asked about.", + ); + final_answer["item"]["phase"] = json!("final_answer"); + let model_server = create_mock_responses_server_sequence_unchecked(vec![ + responses::sse(vec![ + responses::ev_response_created("delegated-response"), + responses::ev_reasoning_item("private-reasoning", &["PRIVATE REASONING"], &[]), + commentary, + final_answer, + responses::ev_completed("delegated-response"), + ]), + responses::sse(vec![ + responses::ev_response_created("typed-response"), + responses::ev_assistant_message("typed-final", "Typed answer stays visible."), + responses::ev_completed("typed-response"), + ]), + ]) + .await; + Mock::given(method("POST")) + .and(path("/v1/live")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Location", "/v1/live/rtc_tui_handoff") + .set_body_string("v=answer\r\n"), + ) + .mount(&model_server) + .await; + let realtime_server = + responses::start_websocket_server_with_headers(vec![WebSocketConnectionConfig { + requests: vec![ + vec![ + json!({ + "type": "session.started", + "session": { "id": "sess_tui_handoff", "instructions": "backend prompt" } + }), + json!({ + "type": "delegation.created", + "offset_ms": 100, + "item": { + "id": "delegation_tui", + "type": "delegation", + "target": "client", + "content": [{"type": "input_text", "text": "explain the marker"}] + } + }), + ], + vec![], + vec![], + vec![], + ], + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: false, + }]) + .await; + + let (mut app, mut app_events, mut ops) = make_test_app_with_channels().await; + let codex_home = tempfile::tempdir()?; + MockResponsesConfig::new(&model_server.uri()) + .with_root_config(&format!( + "experimental_realtime_ws_base_url = {:?}\nexperimental_realtime_webrtc_call_base_url = {:?}", + realtime_server.uri(), + format!("{}/v1", model_server.uri()), + )) + .with_extra_config("[realtime]\nversion = \"v3\"\ntype = \"conversational\"") + .write(codex_home.path())?; + codex_login::login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); + app.config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + app.config.model = Some("mock-model".to_string()); + app.config.model_provider_id = "mock_provider".to_string(); + app.config.model_provider = ModelProviderInfo { + name: "Mock provider for test".to_string(), + base_url: Some(format!("{}/v1", model_server.uri())), + request_max_retries: Some(0), + stream_max_retries: Some(0), + ..ModelProviderInfo::default() + }; + app.config.experimental_realtime_ws_base_url = Some(realtime_server.uri().to_string()); + app.config.experimental_realtime_webrtc_call_base_url = + Some(format!("{}/v1", model_server.uri())); + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + while ops.try_recv().is_ok() {} + let mut tui = crate::tui::test_support::make_test_tui()?; + app_server + .thread_realtime_start( + thread_id, + "v=offer\r\n".to_string(), + /*model*/ None, + /*voice*/ None, + ) + .await?; + // The sideband fixture sends its delegation after the first outbound request. + app_server + .thread_realtime_append_speech(thread_id, "fixture prompt".to_string()) + .await?; + + let mut rendered = Vec::new(); + let speech = timeout(Duration::from_secs(15), async { + loop { + tokio::select! { + event = app_server.next_event() => { + app.handle_app_server_event( + &app_server, + event.expect("embedded app-server should stay connected"), + ).await; + app.drain_active_thread_events(&mut tui).await?; + } + event = app_events.recv() => { + let event = event.expect("TUI event stream should stay open"); + if let AppEvent::InsertHistoryCell(cell) = &event { + rendered.extend(cell.display_lines(/*width*/ 80).into_iter().map(|line| line.to_string())); + } + app.handle_event(&mut tui, &mut app_server, event).await?; + } + op = ops.recv() => { + let op = op.expect("TUI command stream should stay open"); + if matches!(op, Op::RealtimeConversationSpeech { .. }) { + break Ok::<_, color_eyre::Report>(op); + } + app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(op)).await?; + } + } + } + }) + .await??; + assert!( + matches!(&speech, Op::RealtimeConversationSpeech { text, .. } if text.as_str() == "[ANALYSIS] is the marker you asked about."), + "unexpected speech: {speech:?}" + ); + app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech)) + .await?; + assert_eq!( + timeout( + Duration::from_secs(5), + realtime_server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 1), + ) + .await? + .body_json(), + json!({ + "type": "session.context.append", + "content": [{ + "type": "input_text", + "text": "[ANALYSIS] is the marker you asked about." + }], + "channel": "speakable" + }) + ); + let rendered = rendered.join("\n"); + assert!(!rendered.contains("PRIVATE REASONING"), "{rendered}"); + assert!(!rendered.contains("PRIVATE COMMENTARY"), "{rendered}"); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24, + ); + let mut buffer = Buffer::empty(area); + app.chat_widget.render(area, &mut buffer); + let visible = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(!visible.contains("PRIVATE REASONING"), "{visible}"); + assert!(!visible.contains("PRIVATE COMMENTARY"), "{visible}"); + assert!( + ops.try_recv().is_err(), + "a final answer must be sent only once" + ); + + // A typed request on the same live thread must follow the normal visible path. + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("typed follow-up")); + app.chat_widget + .handle_key_event(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )); + let typed_op = next_user_turn_op(&mut ops); + app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(typed_op)) + .await?; + let mut typed_rendered = Vec::new(); + timeout(Duration::from_secs(10), async { + loop { + tokio::select! { + event = app_server.next_event() => { + let event = event.expect("embedded app-server should stay connected"); + let completed = matches!(&event, AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::TurnCompleted(_))); + app.handle_app_server_event(&app_server, event).await; + app.drain_active_thread_events(&mut tui).await?; + if completed { + break Ok::<_, color_eyre::Report>(()); + } + } + event = app_events.recv() => { + let event = event.expect("TUI event stream should stay open"); + if let AppEvent::InsertHistoryCell(cell) = &event { + typed_rendered.extend(cell.display_lines(/*width*/ 80).into_iter().map(|line| line.to_string())); + } + app.handle_event(&mut tui, &mut app_server, event).await?; + } + op = ops.recv() => { + let op = op.expect("TUI command stream should stay open"); + assert!(!matches!(op, Op::RealtimeConversationSpeech { .. }), "typed answer must not be spoken"); + app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(op)).await?; + } + } + } + }) + .await??; + while let Ok(event) = app_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = &event { + typed_rendered.extend( + cell.display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()), + ); + } + app.handle_event(&mut tui, &mut app_server, event).await?; + } + assert!( + typed_rendered + .join("\n") + .contains("Typed answer stays visible."), + "typed final must appear in history: {typed_rendered:?}" + ); + assert!(ops.try_recv().is_err(), "typed answer must not be spoken"); + realtime_server.shutdown().await; + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs new file mode 100644 index 0000000000000000000000000000000000000000..79e257eab1b7543afc620f9179e121c1e3eb479e --- /dev/null +++ b/codex-rs/tui/src/app/tests/realtime_requests.rs @@ -0,0 +1,1925 @@ +//! App-to-RPC coverage for a delegated voice answer without audio hardware. + +use super::*; +use crate::app::tests::session_lifecycle_requests::RealtimeRequestBehavior; +use crate::app::tests::session_lifecycle_requests::recorded_params; +use crate::app::tests::session_lifecycle_requests::start_recording_realtime_speech_app_server; +use crate::app::tests::session_lifecycle_requests::start_recording_remote_app_server; +use crate::chatwidget::commit_realtime_history_events; +use crate::chatwidget::tests::make_chatwidget_manual_with_sender; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use codex_protocol::models::MessagePhase; +use pretty_assertions::assert_eq; +use std::path::Path; + +fn normalize_voice_snapshot_directory(rendered: &str, cwd: &Path) -> String { + let cwd = cwd.display().to_string(); + let placeholder = "/tmp/project"; + let padded_placeholder = format!( + "{placeholder}{}", + " ".repeat(cwd.len().saturating_sub(placeholder.len())) + ); + rendered.replace(&cwd, &padded_placeholder) +} + +#[test] +fn voice_snapshot_directory_keeps_header_width_for_windows_paths() { + let rendered = "│ directory: C:\\tmp\\project │"; + assert_eq!( + normalize_voice_snapshot_directory(rendered, Path::new("C:\\tmp\\project")), + "│ directory: /tmp/project │" + ); +} + +fn test_agent_message(id: &str, text: &str) -> ThreadItem { + ThreadItem::AgentMessage { + id: id.into(), + text: text.into(), + phase: Some(MessagePhase::FinalAnswer), + questions: None, + memory_citation: None, + delivery: None, + } +} + +fn test_user_message(id: &str, text: &str) -> ThreadItem { + ThreadItem::UserMessage { + id: id.into(), + client_id: None, + content: vec![UserInput::Text { + text: text.into(), + text_elements: Vec::new(), + }], + } +} + +fn empty_thread_snapshot(app: &App, thread_id: ThreadId) -> ThreadEventSnapshot { + ThreadEventSnapshot { + delegated_turns: Vec::new(), + session: Some(test_thread_session(thread_id, app.config.cwd.to_path_buf())), + turns: Vec::new(), + events: Vec::new(), + active_reasoning_item: None, + input_state: None, + } +} + +enum ItemEventKind { + Started, + Completed, +} + +#[tokio::test] +async fn remote_voice_start_routes_v3_offer_and_effective_preference() -> Result<()> { + use super::session_lifecycle_requests::HistoryCapabilities; + for (capabilities, expected_voice) in [ + (HistoryCapabilities::Current, Some("juniper")), + (HistoryCapabilities::ConfigReadUnsupported(-32601), None), + (HistoryCapabilities::Current, Some("cove")), + (HistoryCapabilities::VoiceCatalogCustom, Some("maple")), + (HistoryCapabilities::VoiceCatalogUnavailable, Some("cove")), + (HistoryCapabilities::ConfigReadUnknownVoice, None), + ] { + check_remote_voice_start(capabilities, expected_voice).await?; + } + Ok(()) +} + +async fn check_remote_voice_start( + capabilities: super::session_lifecycle_requests::HistoryCapabilities, + expected_voice: Option<&str>, +) -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = + super::session_lifecycle_requests::start_recording_app_server_with_realtime_speech( + &app.config, + capabilities, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + RealtimeRequestBehavior::AcceptStart, + codex_config::LoaderOverrides::default(), + ) + .await?; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + let mut tui = crate::tui::test_support::make_test_tui()?; + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::PersistRealtimeVoiceSelection { + voice: codex_protocol::protocol::RealtimeVoice::Juniper, + }, + )) + .await?; + if capabilities == super::session_lifecycle_requests::HistoryCapabilities::Current { + assert_eq!( + ( + app.config.realtime.voice, + app.chat_widget.config_ref().realtime.voice + ), + ( + Some(codex_protocol::protocol::RealtimeVoice::Juniper), + Some(codex_protocol::protocol::RealtimeVoice::Juniper) + ) + ); + } + assert_eq!( + recorded_params(&requests, "config/batchWrite")[0]["edits"], + serde_json::json!([{"keyPath": "realtime.voice", "value": "juniper", "mergeStrategy": "replace"}]), + ); + if expected_voice != Some("juniper") { + std::fs::write( + app.config.codex_home.join("config.toml"), + "[realtime]\ntype = \"conversational\"\n", + )?; + } + // A reconnected TUI can retain a different local preference from its server. + app.config.realtime.voice = Some(codex_protocol::protocol::RealtimeVoice::Maple); + app.chat_widget + .on_realtime_voice_saved(codex_protocol::protocol::RealtimeVoice::Maple); + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CodexOp(Op::RealtimeConversationStart { + thread_id, + offer_sdp: String::from("v=0\r\n").into(), + }), + )) + .await?; + + let starts = recorded_params(&requests, "thread/realtime/start"); + assert_eq!(starts.len(), 1); + let start = &starts[0]; + assert_eq!( + serde_json::json!({ + "threadId": start["threadId"], + "clientManagedHandoffs": start["clientManagedHandoffs"], + "includeStartupContext": start["includeStartupContext"], + "outputModality": start["outputModality"], + "version": start["version"], + "voice": start["voice"], + "transport": start["transport"], + }), + serde_json::json!({ + "threadId": thread_id.to_string(), + "clientManagedHandoffs": true, + "includeStartupContext": false, + "outputModality": "audio", + "version": "v3", + "voice": expected_voice, + "transport": {"type": "webrtc", "sdp": "v=0\r\n"}, + }) + ); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn shutdown_stops_backend_voice_once_through_app_server() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server( + &app.config, + RealtimeRequestBehavior::AcceptSpeech, + ) + .await?; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + + app.shutdown_current_thread(&mut app_server).await; + app.shutdown_current_thread(&mut app_server).await; + + assert_eq!( + recorded_params(&requests, "thread/realtime/stop"), + vec![serde_json::json!({"threadId": thread_id.to_string()})] + ); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn stalled_voice_stop_leaves_time_for_shutdown_unsubscribe() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server( + &app.config, + RealtimeRequestBehavior::AcceptSpeechAndStallStop, + ) + .await?; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + + tokio::time::timeout( + crate::app::event_dispatch::SHUTDOWN_FIRST_EXIT_TIMEOUT, + app.shutdown_current_thread(&mut app_server), + ) + .await?; + + assert_eq!( + recorded_params(&requests, "thread/realtime/stop"), + vec![serde_json::json!({"threadId": thread_id.to_string()})] + ); + assert_eq!( + recorded_params(&requests, "thread/unsubscribe"), + vec![serde_json::json!({"threadId": thread_id.to_string()})] + ); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn switching_agent_threads_stops_backend_voice_once_through_app_server() -> Result<()> { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server( + &app.config, + RealtimeRequestBehavior::AcceptSpeech, + ) + .await?; + let source = ThreadId::new(); + app.active_thread_id = Some(source); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(source, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, source); + while ops.try_recv().is_ok() {} + let turn_id = "pending-voice-turn"; + send_item( + &mut app, + source, + turn_id, + test_user_message( + "spoken-input", + "question", + ), + ItemEventKind::Started, + ); + let answer = test_agent_message("pending-answer", "Answer to preserve after switching."); + send_item( + &mut app, + source, + turn_id, + answer.clone(), + ItemEventKind::Started, + ); + send_item( + &mut app, + source, + turn_id, + answer.clone(), + ItemEventKind::Completed, + ); + app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: source.to_string(), + turn: Turn { + id: turn_id.into(), + items: vec![answer.clone()], + items_view: TurnItemsView::Summary, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }), + ))); + let speech = ops.try_recv()?; + assert!( + matches!(speech, Op::RealtimeConversationSpeech { .. }), + "{speech:?}" + ); + let mut source_channel = ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + test_thread_session(source, app.config.cwd.to_path_buf()), + Vec::new(), + ); + source_channel.store.lock().await.active = true; + app.active_thread_rx = source_channel.receiver.take(); + app.thread_event_channels.insert(source, source_channel); + // Routed to the active channel, but not yet delivered to the old widget. + app.enqueue_thread_notification( + source, + ServerNotification::ThreadRealtimeTranscriptDone( + codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification { + thread_id: source.to_string(), + role: "user".into(), + text: "queued before switch".into(), + }, + ), + ) + .await?; + let target = ThreadId::new(); + app.thread_event_channels.insert( + target, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + test_thread_session(target, app.config.cwd.to_path_buf()), + Vec::new(), + ), + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + + Box::pin(app.select_agent_thread(&mut tui, &mut app_server, target)).await?; + + assert_eq!(app.chat_widget.thread_id(), Some(target)); + assert_eq!( + app.pending_realtime_speech_replay[&source], + vec![(turn_id.into(), answer)] + ); + assert_eq!(app.pending_realtime_transcript_replay[&source].len(), 1); + assert_eq!( + app.pending_realtime_transcript_replay[&source][0].text, + "queued before switch" + ); + assert_eq!( + recorded_params(&requests, "thread/realtime/stop"), + vec![serde_json::json!({"threadId": source.to_string()})] + ); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn switching_threads_keeps_the_source_voice_partial_only_on_reattach() { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let source = ThreadId::new(); + app.active_thread_id = Some(source); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(source, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, source); + app.chat_widget.handle_server_notification( + ServerNotification::ThreadRealtimeTranscriptDelta( + codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification { + thread_id: source.to_string(), + role: "user".into(), + delta: "spoken partial".into(), + }, + ), + /*replay_kind*/ None, + ); + + let side = ThreadId::new(); + let (side_widget, _, mut side_events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(side); + app.replace_chat_widget(side_widget); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(side, app.config.cwd.to_path_buf())); + assert_eq!(app.pending_realtime_transcript_replay[&source].len(), 1); + while let Ok(event) = side_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + assert!( + cell.transcript_lines(/*width*/ 80) + .iter() + .all(|line| !line.to_string().contains("spoken partial")) + ); + } + } + + let (source_widget, _, mut source_events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(source_widget); + app.replay_thread_snapshot( + empty_thread_snapshot(&app, source), + /*resume_restored_queue*/ false, + ); + assert!(!app.pending_realtime_transcript_replay.contains_key(&source)); + app.chat_widget.handle_server_notification( + ServerNotification::ThreadRealtimeTranscriptDone( + codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification { + thread_id: source.to_string(), + role: "user".into(), + text: "spoken partial completed".into(), + }, + ), + /*replay_kind*/ None, + ); + commit_realtime_history_events(&mut app.chat_widget, &mut source_events); + let rendered = std::iter::from_fn(|| source_events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + assert_eq!(rendered.matches("spoken partial completed").count(), 1); + insta::assert_snapshot!( + "voice_partial_completed_after_thread_switch", + normalize_voice_snapshot_directory(&rendered, &app.config.cwd) + ); + while let Ok(event) = side_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + assert!( + cell.transcript_lines(/*width*/ 80) + .iter() + .all(|line| !line.to_string().contains("spoken partial")) + ); + } + } +} + +#[tokio::test] +async fn queued_voice_caption_after_switch_returns_once_to_its_source_thread() { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let source = ThreadId::new(); + app.active_thread_id = Some(source); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(source, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, source); + + // Exercise a completion that arrives only after two switches without a prior delta. + for _ in 0..2 { + let (other_widget, _, _, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(ThreadId::new()); + app.replace_chat_widget(other_widget); + assert!(app.pending_realtime_transcript_replay.contains_key(&source)); + + let (source_widget, _, _, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(source_widget); + app.replay_thread_snapshot( + empty_thread_snapshot(&app, source), + /*resume_restored_queue*/ false, + ); + } + + let other = ThreadId::new(); + let (other_widget, _, mut other_events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(other); + app.replace_chat_widget(other_widget); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(other, app.config.cwd.to_path_buf())); + + app.enqueue_thread_notification( + source, + ServerNotification::ThreadRealtimeTranscriptDone( + codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification { + thread_id: source.to_string(), + role: "assistant".into(), + text: "arrived after two switches".into(), + }, + ), + ) + .await + .unwrap(); + + for notification in [ + ServerNotification::ThreadRealtimeTranscriptDelta( + codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification { + thread_id: source.to_string(), + role: "user".into(), + delta: "last ".into(), + }, + ), + ServerNotification::ThreadRealtimeTranscriptDelta( + codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification { + thread_id: source.to_string(), + role: "user".into(), + delta: "words".into(), + }, + ), + ServerNotification::ThreadRealtimeTranscriptDone( + codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification { + thread_id: source.to_string(), + role: "user".into(), + text: "last words".into(), + }, + ), + ] { + app.enqueue_thread_notification(source, notification) + .await + .unwrap(); + } + app.enqueue_thread_notification( + source, + ServerNotification::ThreadRealtimeTranscriptDelta( + codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification { + thread_id: source.to_string(), + role: "assistant".into(), + delta: "discarded partial".into(), + }, + ), + ) + .await + .unwrap(); + app.enqueue_thread_notification( + source, + ServerNotification::ThreadRealtimeTranscriptDone( + codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification { + thread_id: source.to_string(), + role: "assistant".into(), + text: String::new(), + }, + ), + ) + .await + .unwrap(); + assert_eq!(app.pending_realtime_transcript_replay[&source].len(), 2); + while let Ok(event) = other_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + assert!( + cell.transcript_lines(/*width*/ 80) + .iter() + .all(|line| !line.to_string().contains("last words")) + ); + } + } + + let (source_widget, _, mut source_events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(source_widget); + app.replay_thread_snapshot( + empty_thread_snapshot(&app, source), + /*resume_restored_queue*/ false, + ); + commit_realtime_history_events(&mut app.chat_widget, &mut source_events); + let rendered = std::iter::from_fn(|| source_events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::(), + ), + _ => None, + }) + .collect::(); + assert_eq!(rendered.matches("last words").count(), 1); + assert_eq!(rendered.matches("arrived after two switches").count(), 1); + assert!(!rendered.contains("last last words")); +} + +#[tokio::test] +async fn replay_reconciles_only_matching_voice_captions_one_for_one() { + let (mut app, _initial_events, _ops) = make_test_app_with_channels().await; + let source = ThreadId::new(); + let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(widget); + let records = [ + ("user", "show & tests"), + ("user", "show & tests"), + ("assistant", "spoken answer"), + ("user", "typed words"), + ("assistant", "different caption"), + ] + .into_iter() + .map(|(role, text)| crate::chatwidget::RealtimeTranscriptRecord { + role: role.to_string(), + text: text.to_string(), + complete: true, + before_turn_id: None, + }) + .collect(); + app.pending_realtime_transcript_replay + .insert(source, records); + let voice = Turn { + id: "voice-turn".into(), + items: vec![ + test_user_message( + "voice-user", + "show <code> & tests", + ), + test_agent_message("voice-answer", "spoken answer"), + ], + items_view: TurnItemsView::Summary, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }; + let typed = Turn { + id: "typed-turn".into(), + items: vec![test_user_message("typed-user", "typed words")], + ..voice.clone() + }; + app.replay_thread_snapshot( + ThreadEventSnapshot { + delegated_turns: vec![voice.id.clone()], + session: Some(test_thread_session(source, app.config.cwd.to_path_buf())), + turns: vec![voice, typed], + events: Vec::new(), + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + commit_realtime_history_events(&mut app.chat_widget, &mut events); + let rendered = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + assert_eq!(rendered.matches("show & tests").count(), 2); + assert_eq!(rendered.matches("spoken answer").count(), 1); + assert_eq!(rendered.matches("typed words").count(), 2); + assert_eq!(rendered.matches("different caption").count(), 1); + insta::assert_snapshot!( + "voice_replay_reconciles_matching_captions", + normalize_voice_snapshot_directory(&rendered, &app.config.cwd) + ); +} + +#[tokio::test] +async fn retained_caption_consumes_only_one_matching_answer_fallback_on_reattach() -> Result<()> { + let (mut app, _initial_events, _ops) = make_test_app_with_channels().await; + let source = ThreadId::new(); + let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(widget); + app.pending_realtime_transcript_replay.insert( + source, + [crate::chatwidget::RealtimeTranscriptRecord { + role: "assistant".into(), + text: "Same answer".into(), + complete: true, + before_turn_id: None, + }] + .into(), + ); + let answer = |id: &str, text: &str| ThreadItem::AgentMessage { + id: id.into(), + text: text.into(), + phase: Some(MessagePhase::FinalAnswer), + questions: None, + memory_citation: None, + delivery: None, + }; + app.pending_realtime_speech_replay.insert( + source, + vec![ + ("turn-1".into(), answer("answer-1", "[FINAL] Same answer")), + ("turn-2".into(), answer("answer-2", "Same answer")), + ("turn-3".into(), answer("answer-3", "Different answer")), + ], + ); + app.replay_thread_snapshot( + empty_thread_snapshot(&app, source), + /*resume_restored_queue*/ false, + ); + let (mut app_server, _requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + while let Ok(event) = events.try_recv() { + Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + app.chat_widget.pre_draw_tick(); + } + let rendered = app + .transcript_cells + .iter() + .flat_map(|cell| cell.display_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert_eq!(rendered.matches("Same answer").count(), 1); + assert_eq!(rendered.matches("Same answer").count(), 1); + assert_eq!(rendered.matches("Different answer").count(), 1); + assert!(!rendered.contains("[FINAL]")); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn inactive_voice_replay_is_bounded_and_discarded_with_its_thread() { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let ids = (0..17).map(|_| ThreadId::new()).collect::>(); + for (index, thread_id) in ids.iter().enumerate() { + app.retain_inactive_realtime_transcript( + *thread_id, + &ServerNotification::ThreadRealtimeTranscriptDone( + codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification { + thread_id: thread_id.to_string(), + role: "assistant".into(), + text: format!("caption {index}"), + }, + ), + ); + } + assert_eq!(app.realtime_replay_order.len(), 16); + assert!(!app.pending_realtime_transcript_replay.contains_key(&ids[0])); + assert!( + app.pending_realtime_transcript_replay + .contains_key(&ids[16]) + ); + + app.discard_thread_local_state(ids[16]).await; + assert!( + !app.pending_realtime_transcript_replay + .contains_key(&ids[16]) + ); + assert!(!app.realtime_replay_order.contains(&ids[16])); + app.reset_thread_event_state(); + assert!(app.pending_realtime_transcript_replay.is_empty()); + assert!(app.pending_realtime_speech_replay.is_empty()); + assert!(app.realtime_replay_order.is_empty()); +} + +#[tokio::test] +async fn buffered_voice_items_reconcile_captions_after_thread_switch() { + let (mut app, _initial_events, _ops) = make_test_app_with_channels().await; + let source = ThreadId::new(); + let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(widget); + app.pending_realtime_transcript_replay.insert( + source, + [ + ("user", "buffered question"), + ("assistant", "buffered answer"), + ] + .into_iter() + .map(|(role, text)| crate::chatwidget::RealtimeTranscriptRecord { + role: role.to_string(), + text: text.to_string(), + complete: true, + before_turn_id: None, + }) + .collect(), + ); + let items = [ + ThreadItem::UserMessage { + id: "user-item".into(), + client_id: None, + content: vec![UserInput::Text { + text: "buffered question" + .into(), + text_elements: Vec::new(), + }], + }, + test_agent_message("answer-item", "buffered answer"), + ]; + let events_to_replay = items + .into_iter() + .map(|item| { + ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item, + thread_id: source.to_string(), + turn_id: "buffered-turn".into(), + completed_at_ms: 0, + }, + ))) + }) + .collect(); + app.replay_thread_snapshot( + ThreadEventSnapshot { + delegated_turns: vec!["buffered-turn".into()], + session: Some(test_thread_session(source, app.config.cwd.to_path_buf())), + turns: Vec::new(), + events: events_to_replay, + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + let rendered = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + assert_eq!(rendered.matches("buffered question").count(), 1); + assert_eq!(rendered.matches("buffered answer").count(), 1); + insta::assert_snapshot!( + "voice_buffered_replay_reconciles_captions", + normalize_voice_snapshot_directory(&rendered, &app.config.cwd) + ); +} + +#[tokio::test] +async fn unrendered_buffered_items_do_not_consume_retained_captions() { + let (mut app, _initial_events, _ops) = make_test_app_with_channels().await; + let source = ThreadId::new(); + let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(widget); + app.pending_realtime_transcript_replay.insert( + source, + [ + ("user", "started only"), + ("user", "evicted prompt"), + ("assistant", "interrupted answer"), + ] + .into_iter() + .map(|(role, text)| crate::chatwidget::RealtimeTranscriptRecord { + role: role.to_string(), + text: text.to_string(), + complete: true, + before_turn_id: None, + }) + .collect(), + ); + let user_item = |id: &str, text: &str| ThreadItem::UserMessage { + id: id.into(), + client_id: None, + content: vec![UserInput::Text { + text: format!("{text}"), + text_elements: Vec::new(), + }], + }; + let completion = |turn_id: &str, status: TurnStatus, items: Vec| { + ThreadBufferedEvent::Notification(Box::new(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: source.to_string(), + turn: Turn { + id: turn_id.into(), + items, + items_view: TurnItemsView::Summary, + status, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + ))) + }; + app.replay_thread_snapshot( + ThreadEventSnapshot { + delegated_turns: vec!["started".into(), "evicted".into(), "interrupted".into()], + session: Some(test_thread_session(source, app.config.cwd.to_path_buf())), + turns: Vec::new(), + events: vec![ + ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + item: user_item("started-user", "started only"), + thread_id: source.to_string(), + turn_id: "started".into(), + started_at_ms: 0, + }, + ))), + completion( + "evicted", + TurnStatus::Completed, + vec![user_item("evicted-user", "evicted prompt")], + ), + completion( + "interrupted", + TurnStatus::Interrupted, + vec![test_agent_message( + "interrupted-agent", + "interrupted answer", + )], + ), + ], + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + commit_realtime_history_events(&mut app.chat_widget, &mut events); + let rendered = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + for text in ["started only", "evicted prompt", "interrupted answer"] { + assert_eq!( + rendered.matches(text).count(), + 1, + "{text} should survive replay" + ); + } +} + +#[tokio::test] +async fn completed_voice_caption_survives_repeated_thread_replacement() { + let (mut app, mut initial_events, _ops) = make_test_app_with_channels().await; + let source = ThreadId::new(); + app.active_thread_id = Some(source); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(source, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, source); + app.chat_widget.handle_server_notification( + ServerNotification::ThreadRealtimeTranscriptDone( + codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification { + thread_id: source.to_string(), + role: "assistant".into(), + text: "spoken complete".into(), + }, + ), + /*replay_kind*/ None, + ); + commit_realtime_history_events(&mut app.chat_widget, &mut initial_events); + let initial = std::iter::from_fn(|| initial_events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::(), + ), + _ => None, + }) + .collect::(); + assert_eq!(initial.matches("spoken complete").count(), 1); + + let typed = test_turn( + "later-typed-turn", + TurnStatus::Completed, + vec![test_user_message("later-user", "Later typed question")], + ); + app.chat_widget.handle_server_notification( + turn_started_notification(source, &typed.id), + /*replay_kind*/ None, + ); + + for cycle in 0..2 { + let side = ThreadId::new(); + let (side_widget, _, mut side_events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(side); + app.replace_chat_widget(side_widget); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(side, app.config.cwd.to_path_buf())); + assert_eq!(app.pending_realtime_transcript_replay[&source].len(), 1); + while let Ok(event) = side_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + assert!( + cell.transcript_lines(/*width*/ 80) + .iter() + .all(|line| !line.to_string().contains("spoken complete")) + ); + } + } + + let (source_widget, _, mut source_events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(source_widget); + let mut snapshot = empty_thread_snapshot(&app, source); + snapshot.turns.push(Turn { + id: "earlier-typed-turn".into(), + items: vec![test_agent_message("earlier-answer", "Earlier answer")], + ..typed.clone() + }); + snapshot.turns.push(typed.clone()); + app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); + assert!(!app.pending_realtime_transcript_replay.contains_key(&source)); + commit_realtime_history_events(&mut app.chat_widget, &mut source_events); + let rendered = std::iter::from_fn(|| source_events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + assert_eq!(rendered.matches("spoken complete").count(), 1); + assert!( + rendered.find("Earlier answer").unwrap() < rendered.find("spoken complete").unwrap() + ); + assert!( + rendered.find("spoken complete").unwrap() + < rendered.find("Later typed question").unwrap() + ); + if cycle == 0 { + insta::assert_snapshot!( + "voice_completed_after_thread_switch", + normalize_voice_snapshot_directory(&rendered, &app.config.cwd) + ); + } + } +} + +#[tokio::test] +async fn inactive_caption_precedes_later_buffered_turn_without_start_event() { + for include_delta in [false, true] { + let (mut app, _initial_events, _ops) = make_test_app_with_channels().await; + let source = ThreadId::new(); + app.retain_inactive_realtime_transcript( + source, + &ServerNotification::ThreadRealtimeTranscriptDone( + codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification { + thread_id: source.to_string(), + role: "assistant".into(), + text: "Earlier spoken answer".into(), + }, + ), + ); + app.retain_inactive_realtime_transcript( + source, + &turn_started_notification(source, "later-turn"), + ); + let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(source); + app.replace_chat_widget(widget); + let mut snapshot = empty_thread_snapshot(&app, source); + if include_delta { + snapshot + .events + .push(ThreadBufferedEvent::Notification(Box::new( + ServerNotification::AgentMessageDelta( + codex_app_server_protocol::AgentMessageDeltaNotification { + thread_id: source.to_string(), + turn_id: "later-turn".into(), + item_id: "later-answer".into(), + delta: "Later typed question".into(), + }, + ), + ))); + } + // The bounded replay buffer may have evicted both the start and item notifications. + snapshot + .events + .push(ThreadBufferedEvent::Notification(Box::new( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: source.to_string(), + turn: test_turn( + "later-turn", + TurnStatus::Completed, + vec![test_agent_message("later-answer", "Later typed question")], + ), + }), + ))); + app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false); + let rendered = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.transcript_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + assert_eq!(rendered.matches("Earlier spoken answer").count(), 1); + assert!( + rendered.find("Earlier spoken answer").unwrap() + < rendered.find("Later typed question").unwrap() + ); + } +} + +#[tokio::test] +async fn rejected_realtime_speech_restores_the_delegated_final_answer() -> Result<()> { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + // This proxy forwards appendSpeech to an embedded server with no voice + // session for the synthetic thread, which rejects the request. + let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + while ops.try_recv().is_ok() {} + send_item( + &mut app, + thread_id, + "rejected-turn", + test_user_message( + "voice-input", + "hello", + ), + ItemEventKind::Started, + ); + let answer = test_agent_message("rejected-final", "An answer to keep."); + send_item( + &mut app, + thread_id, + "rejected-turn", + answer.clone(), + ItemEventKind::Started, + ); + send_item( + &mut app, + thread_id, + "rejected-turn", + answer.clone(), + ItemEventKind::Completed, + ); + app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: "rejected-turn".to_string(), + items: vec![answer], + items_view: TurnItemsView::Summary, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }), + ))); + let speech = ops.try_recv()?; + let delivery_id = match &speech { + AppCommand::RealtimeConversationSpeech { delivery_id, .. } => *delivery_id, + _ => unreachable!("voice completion queues speech"), + }; + while events.try_recv().is_ok() {} + let mut tui = crate::tui::test_support::make_test_tui()?; + Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech))).await?; + assert_eq!( + recorded_params(&requests, "thread/realtime/appendSpeech").len(), + 1 + ); + assert!(!app.chat_widget.has_pending_realtime_speech(delivery_id)); + let mut restored = 0; + let mut rendered = Vec::new(); + while let Ok(event) = events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let lines = cell + .display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>(); + if lines.iter().any(|line| line.contains("An answer to keep.")) { + restored += 1; + } + rendered.extend(lines); + } + } + assert_eq!(restored, 1); + assert!( + rendered + .iter() + .any(|line| line.contains("Voice conversation failed:")) + ); + insta::assert_snapshot!( + "rejected_realtime_speech", + rendered + .join("\n") + .replace(&thread_id.to_string(), "") + ); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn switching_threads_retains_undelivered_voice_answer_after_replay_eviction() -> Result<()> { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server( + &app.config, + RealtimeRequestBehavior::AcceptSpeech, + ) + .await?; + let original = ThreadId::new(); + app.active_thread_id = Some(original); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(original, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, original); + while ops.try_recv().is_ok() {} + send_item( + &mut app, + original, + "switched-turn", + test_user_message( + "voice-input", + "hello", + ), + ItemEventKind::Started, + ); + let answer = test_agent_message("switched-final", "Answer survives a thread switch."); + send_item( + &mut app, + original, + "switched-turn", + answer.clone(), + ItemEventKind::Started, + ); + send_item( + &mut app, + original, + "switched-turn", + answer.clone(), + ItemEventKind::Completed, + ); + app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: original.to_string(), + turn: Turn { + id: "switched-turn".to_string(), + items: vec![answer], + items_view: TurnItemsView::Summary, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }), + ))); + let speech = ops.try_recv()?; + let (side_widget, _, mut side_events, _) = make_chatwidget_manual_with_sender().await; + let side = ThreadId::new(); + app.active_thread_id = Some(side); + app.replace_chat_widget(side_widget); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(side, app.config.cwd.to_path_buf())); + assert_eq!(app.pending_realtime_speech_replay[&original].len(), 1); + // Reconnect discards the old AppEvent receiver before installing a new widget. + let (replacement_tx, _replacement_rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = crate::app_event_sender::AppEventSender::new(replacement_tx); + + let mut tui = crate::tui::test_support::make_test_tui()?; + Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech))).await?; + assert!(recorded_params(&requests, "thread/realtime/appendSpeech").is_empty()); + while let Ok(event) = side_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + assert!(cell.display_lines(/*width*/ 80).iter().all(|line| { + !line + .to_string() + .contains("Answer survives a thread switch.") + })); + } + } + + let (original_widget, _, mut original_events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(original); + app.replace_chat_widget(original_widget); + // The ordinary bounded event buffer has lost ItemCompleted and TurnCompleted. + app.replay_thread_snapshot( + empty_thread_snapshot(&app, original), + /*resume_restored_queue*/ false, + ); + assert!(!app.pending_realtime_speech_replay.contains_key(&original)); + let mut restored = 0; + while let Ok(event) = original_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event + && cell.display_lines(/*width*/ 80).iter().any(|line| { + line.to_string() + .contains("Answer survives a thread switch.") + }) + { + restored += 1; + } + } + assert_eq!(restored, 1); + let replayed_item = test_agent_message("replayed-final", "Already in the thread snapshot."); + let mut older_item = replayed_item.clone(); + if let ThreadItem::AgentMessage { text, .. } = &mut older_item { + *text = "Older pending text.".to_string(); + } + app.pending_realtime_speech_replay + .insert(original, vec![("replayed-turn".to_string(), older_item)]); + app.replay_thread_snapshot( + ThreadEventSnapshot { + delegated_turns: Vec::new(), + session: Some(test_thread_session(original, app.config.cwd.to_path_buf())), + turns: vec![Turn { + id: "replayed-turn".to_string(), + items: vec![replayed_item], + items_view: TurnItemsView::Summary, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + events: Vec::new(), + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + let mut snapshot_cells = 0; + while let Ok(event) = original_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + for line in cell.display_lines(/*width*/ 80) { + assert!(!line.to_string().contains("Older pending text.")); + snapshot_cells += + usize::from(line.to_string().contains("Already in the thread snapshot.")); + } + } + } + assert_eq!(snapshot_cells, 1); + assert!(!app.pending_realtime_speech_replay.contains_key(&original)); + + // Switch before TurnCompleted exists at all. A hidden ItemCompleted must + // survive without relying on a later turn event in the bounded buffer. + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, original); + send_item( + &mut app, + original, + "early-switch-turn", + test_user_message( + "early-voice-input", + "early", + ), + ItemEventKind::Started, + ); + let early_answer = + test_agent_message("early-switch-final", "Hidden final before turn completion."); + send_item( + &mut app, + original, + "early-switch-turn", + early_answer.clone(), + ItemEventKind::Started, + ); + send_item( + &mut app, + original, + "early-switch-turn", + early_answer, + ItemEventKind::Completed, + ); + assert!(ops.try_recv().is_err()); + let (side_widget, _, _, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(side); + app.replace_chat_widget(side_widget); + assert_eq!(app.pending_realtime_speech_replay[&original].len(), 1); + let (original_widget, _, mut early_events, _) = make_chatwidget_manual_with_sender().await; + app.active_thread_id = Some(original); + app.replace_chat_widget(original_widget); + app.replay_thread_snapshot( + empty_thread_snapshot(&app, original), + /*resume_restored_queue*/ false, + ); + let mut early_restored = 0; + while let Ok(event) = early_events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event + && cell.display_lines(/*width*/ 80).iter().any(|line| { + line.to_string() + .contains("Hidden final before turn completion.") + }) + { + early_restored += 1; + } + } + assert_eq!(early_restored, 1); + assert!(!app.pending_realtime_speech_replay.contains_key(&original)); + // Primary resume/fork uses a different replay entry point than a thread + // switch; it must also drain the old widget's per-thread delivery stash. + let resumed_answer = test_agent_message("primary-resume-final", "Recovered on primary resume."); + app.pending_realtime_speech_replay.insert( + original, + vec![("primary-resume-turn".to_string(), resumed_answer)], + ); + app.enqueue_primary_thread_session_with_presentation( + test_thread_session(original, app.config.cwd.to_path_buf()), + Vec::new(), + crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage, + ) + .await?; + assert!(!app.pending_realtime_speech_replay.contains_key(&original)); + let primary_restored = std::iter::from_fn(|| early_events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell.raw_lines()), + _ => None, + }) + .flatten() + .filter(|line| line.to_string() == "Recovered on primary resume.") + .count(); + assert_eq!(primary_restored, 1); + assert!(recorded_params(&requests, "thread/realtime/appendSpeech").is_empty()); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +fn send_item( + app: &mut App, + thread_id: ThreadId, + turn_id: &str, + item: ThreadItem, + kind: ItemEventKind, +) { + let notification = match kind { + ItemEventKind::Started => ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item, + started_at_ms: 0, + }), + ItemEventKind::Completed => ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item, + completed_at_ms: 0, + }), + }; + app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(notification))); +} + +#[tokio::test] +async fn delegated_final_speech_reaches_app_server_once_and_stale_speech_is_rejected() -> Result<()> +{ + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server( + &app.config, + RealtimeRequestBehavior::AcceptSpeech, + ) + .await?; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + while ops.try_recv().is_ok() {} + let turn_id = "delegated-voice-turn"; + let delegation = test_user_message( + "voice-input", + "hello", + ); + let answer = test_agent_message("voice-final", "Hello back."); + send_item( + &mut app, + thread_id, + turn_id, + delegation.clone(), + ItemEventKind::Started, + ); + send_item( + &mut app, + thread_id, + turn_id, + delegation, + ItemEventKind::Completed, + ); + send_item( + &mut app, + thread_id, + turn_id, + answer.clone(), + ItemEventKind::Started, + ); + send_item( + &mut app, + thread_id, + turn_id, + answer.clone(), + ItemEventKind::Completed, + ); + send_item( + &mut app, + thread_id, + turn_id, + answer.clone(), + ItemEventKind::Completed, + ); + app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: turn_id.to_string(), + items: vec![answer], + items_view: TurnItemsView::Summary, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }), + ))); + + let speech = ops.try_recv()?; + let delivery_id = match &speech { + Op::RealtimeConversationSpeech { delivery_id, .. } => *delivery_id, + _ => unreachable!("voice completion queues speech"), + }; + assert!( + matches!(&speech, Op::RealtimeConversationSpeech { text, .. } if text.as_str() == "Hello back."), + "unexpected command: {speech:?}" + ); + assert!(ops.try_recv().is_err()); + let mut tui = crate::tui::test_support::make_test_tui()?; + Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech.clone()))) + .await?; + assert!(!app.chat_widget.has_pending_realtime_speech(delivery_id)); + assert_eq!( + recorded_params(&requests, "thread/realtime/appendSpeech"), + vec![serde_json::json!({"threadId": thread_id.to_string(), "text": "Hello back."})] + ); + + let typed = test_user_message("typed-input", "type instead"); + send_item( + &mut app, + thread_id, + "typed-turn", + typed, + ItemEventKind::Started, + ); + Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech))).await?; + assert_eq!( + recorded_params(&requests, "thread/realtime/appendSpeech").len(), + 1 + ); + // A completed second turn may be queued when voice closes. The old command + // must not reach the app server or duplicate the restored final history. + let second_turn = "second-delegated-voice-turn"; + let second_input = test_user_message( + "second-voice-input", + "again", + ); + send_item( + &mut app, + thread_id, + second_turn, + second_input, + ItemEventKind::Started, + ); + let second_answer = test_agent_message("second-final", "Second answer."); + send_item( + &mut app, + thread_id, + second_turn, + second_answer.clone(), + ItemEventKind::Started, + ); + send_item( + &mut app, + thread_id, + second_turn, + second_answer.clone(), + ItemEventKind::Completed, + ); + app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.to_string(), + turn: Turn { + id: second_turn.to_string(), + items: vec![second_answer], + items_view: TurnItemsView::Summary, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }), + ))); + let queued = ops.try_recv()?; + let queued_delivery_id = match &queued { + Op::RealtimeConversationSpeech { delivery_id, .. } => *delivery_id, + _ => unreachable!("second voice completion queues speech"), + }; + assert!( + app.chat_widget + .has_pending_realtime_speech(queued_delivery_id) + ); + app.chat_widget.reset_realtime_conversation(); + assert!( + !app.chat_widget + .has_pending_realtime_speech(queued_delivery_id) + ); + Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(queued))).await?; + assert_eq!( + recorded_params(&requests, "thread/realtime/appendSpeech").len(), + 1 + ); + let restored = events + .try_recv() + .ok() + .into_iter() + .chain(std::iter::from_fn(|| events.try_recv().ok())) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::(), + ), + _ => None, + }) + .filter(|line| line.contains("Second answer.")) + .count(); + assert_eq!(restored, 1); + while events.try_recv().is_ok() {} + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn rejected_voice_setting_preserves_current_selection() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let (mut app_server, _requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let previous = ( + app.config.realtime.voice, + app.chat_widget.config_ref().realtime.voice, + ); + std::fs::write(app.config.codex_home.join("config.toml"), "realtime = [")?; + let mut tui = crate::tui::test_support::make_test_tui()?; + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::PersistRealtimeVoiceSelection { + voice: codex_protocol::protocol::RealtimeVoice::Juniper, + }, + )) + .await?; + assert_eq!( + ( + app.config.realtime.voice, + app.chat_widget.config_ref().realtime.voice + ), + previous, + ); + let messages = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + assert!(messages.contains("Failed to save voice")); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_voice_config_read_failure_does_not_start_with_local_voice() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + std::fs::write(app.config.codex_home.join("config.toml"), "realtime = [")?; + let thread_id = ThreadId::new(); + let error = app + .try_submit_active_thread_op_via_app_server( + &mut app_server, + thread_id, + &AppCommand::RealtimeConversationStart { + thread_id, + offer_sdp: String::from("v=0\r\n").into(), + }, + ) + .await + .expect_err("invalid server config must not fall back to local voice"); + assert!(error.to_string().contains("config/read")); + assert!(recorded_params(&requests, "thread/realtime/start").is_empty()); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn embedded_voice_settings_follow_project_after_thread_switch() -> Result<()> { + use codex_protocol::protocol::RealtimeVoice; + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let projects = tempfile::tempdir()?; + for voice in [RealtimeVoice::Maple, RealtimeVoice::Sol] { + let cwd = projects.path().join(voice.wire_name()); + std::fs::create_dir_all(cwd.join(".codex"))?; + std::fs::write( + cwd.join(".codex/config.toml"), + format!("[realtime]\nvoice = \"{}\"\n", voice.wire_name()), + )?; + crate::legacy_core::config::set_project_trust_level( + &app.config.codex_home, + &cwd, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } + app.config.realtime.voice = Some(RealtimeVoice::Maple); + let (mut server, requests, proxy) = start_recording_realtime_speech_app_server( + &app.config, + RealtimeRequestBehavior::AcceptStart, + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + for voice in [ + RealtimeVoice::Maple, + RealtimeVoice::Sol, + RealtimeVoice::Maple, + ] { + let thread_id = ThreadId::new(); + let cwd = projects.path().join(voice.wire_name()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + test_thread_session(thread_id, cwd), + Vec::new(), + ), + ); + Box::pin(app.select_agent_thread(&mut tui, &mut server, thread_id)).await?; + app.open_realtime_settings(&server).await; + let popup = crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 80, + ); + assert!( + popup.contains(&format!("{} (current)", voice.wire_name())), + "{popup}" + ); + app.try_submit_active_thread_op_via_app_server( + &mut server, + thread_id, + &AppCommand::RealtimeConversationStart { + thread_id, + offer_sdp: String::from("v=0\r\n").into(), + }, + ) + .await?; + assert_eq!( + recorded_params(&requests, "thread/realtime/start") + .last() + .unwrap()["voice"], + serde_json::json!(voice), + ); + } + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_voice_picker_reads_server_preference() -> Result<()> { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[realtime]\nvoice = \"sol\"\n", + )?; + app.chat_widget + .set_realtime_voice(Some(codex_protocol::protocol::RealtimeVoice::Maple)); + let (server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + app.open_realtime_settings(&server).await; + assert_eq!( + recorded_params(&requests, "thread/realtime/listVoices").len(), + 1 + ); + insta::assert_snapshot!( + "remote_voice_picker", + crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_voice_picker_uses_server_catalog_and_falls_back_when_unavailable() -> Result<()> { + use super::session_lifecycle_requests::HistoryCapabilities; + for (capabilities, expected_catalog) in [ + (HistoryCapabilities::VoiceCatalogCustom, true), + (HistoryCapabilities::VoiceCatalogUnavailable, false), + ] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (server, requests, proxy) = + super::session_lifecycle_requests::start_recording_app_server_with_realtime_speech( + &app.config, + capabilities, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + RealtimeRequestBehavior::Forward, + codex_config::LoaderOverrides::default(), + ) + .await?; + app.open_realtime_settings(&server).await; + let popup = crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 80, + ); + assert_eq!(popup.contains("1. maple (current)"), expected_catalog); + assert_eq!(popup.contains(" 3. spruce"), !expected_catalog); + if expected_catalog { + insta::assert_snapshot!("remote_voice_picker_server_default", popup); + } + assert_eq!( + recorded_params(&requests, "thread/realtime/listVoices").len(), + 1 + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn overridden_voice_save_keeps_effective_voice() -> Result<()> { + for project_override in [false, true] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let project = tempfile::tempdir()?; + let overrides = if project_override { + std::fs::create_dir_all(project.path().join(".codex"))?; + std::fs::write( + project.path().join(".codex/config.toml"), + "[realtime]\nvoice = \"maple\"\n", + )?; + crate::legacy_core::config::set_project_trust_level( + &app.config.codex_home, + project.path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + app.config.cwd = AbsolutePathBuf::from_absolute_path(project.path())?; + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + ThreadId::new(), + project.path().to_path_buf(), + )); + Vec::new() + } else { + vec![( + "realtime.voice".to_string(), + toml::Value::String("maple".to_string()), + )] + }; + let client = crate::start_embedded_app_server( + codex_arg0::Arg0DispatchPaths::default(), + app.config.clone(), + overrides, + codex_config::LoaderOverrides::without_managed_config_for_tests(), + /*strict_config*/ false, + codex_config::CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + /*state_db*/ None, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ) + .await?; + let server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess(client), + crate::app_server_session::ThreadParamsMode::Embedded, + ); + while events.try_recv().is_ok() {} + app.persist_realtime_voice(&server, codex_protocol::protocol::RealtimeVoice::Juniper) + .await; + assert_eq!( + ( + app.config.realtime.voice, + app.chat_widget.config_ref().realtime.voice + ), + ( + Some(codex_protocol::protocol::RealtimeVoice::Maple), + Some(codex_protocol::protocol::RealtimeVoice::Maple) + ), + ); + let message = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + assert!(message.contains("saved but not applied")); + if !project_override { + insta::assert_snapshot!("overridden_voice_save", message); + } + server.shutdown().await?; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/realtime_start.rs b/codex-rs/tui/src/app/tests/realtime_start.rs new file mode 100644 index 0000000000000000000000000000000000000000..f03d1d151d6360a268abd162dd0007fae3c107ce --- /dev/null +++ b/codex-rs/tui/src/app/tests/realtime_start.rs @@ -0,0 +1,100 @@ +//! Voice startup rejection coverage without an audio device. + +use super::*; +use crate::app::tests::session_lifecycle_requests::recorded_params; +use crate::app::tests::session_lifecycle_requests::start_recording_remote_app_server; + +#[tokio::test] +async fn rejected_voice_start_does_not_leave_local_capture_active() -> Result<()> { + for replay_only in [false, true] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = + start_recording_remote_app_server(&app.config).await?; + let thread_id = ThreadId::new(); + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + thread_id, + app.config.cwd.to_path_buf(), + )); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + assert!(app.chat_widget.may_receive_realtime_transcripts()); + if replay_only { + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?, + }; + app.active_thread_id = Some(thread_id); + app.ensure_thread_channel(thread_id).mark_replay_only(); + assert!(app.thread_unavailable(thread_id)); + } + let mut tui = crate::tui::test_support::make_test_tui()?; + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CodexOp(Op::RealtimeConversationStart { + thread_id, + offer_sdp: String::from("v=0\r\n").into(), + }), + )) + .await?; + + assert!(!app.chat_widget.may_receive_realtime_transcripts()); + assert!(recorded_params(&requests, "thread/realtime/start").is_empty()); + let rendered = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some( + cell.display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"), + ), + _ => None, + }) + .collect::>() + .join("\n"); + if replay_only { + insta::assert_snapshot!("rejected_voice_start_replay_only", rendered); + } else { + insta::assert_snapshot!("rejected_voice_start_no_active_thread", rendered); + } + app_server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn rejected_voice_stop_clears_the_owned_session() -> Result<()> { + for replay_only in [false, true] { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let (mut app_server, requests, proxy) = + start_recording_remote_app_server(&app.config).await?; + let thread_id = ThreadId::new(); + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + thread_id, + app.config.cwd.to_path_buf(), + )); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id); + if replay_only { + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?, + }; + app.active_thread_id = Some(thread_id); + app.ensure_thread_channel(thread_id).mark_replay_only(); + } + let mut tui = crate::tui::test_support::make_test_tui()?; + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CodexOp(Op::RealtimeConversationStop { thread_id }), + )) + .await?; + + assert!(!app.chat_widget.may_receive_realtime_transcripts()); + assert!(recorded_params(&requests, "thread/realtime/stop").is_empty()); + app_server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/reasoning_resume_tests.rs b/codex-rs/tui/src/app/tests/reasoning_resume_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..526a0ee8e093814e87962fce3a0391bedbbe5090 --- /dev/null +++ b/codex-rs/tui/src/app/tests/reasoning_resume_tests.rs @@ -0,0 +1,180 @@ +//! Resume snapshots must accept an unfinished reasoning stream without an earlier item/started. + +use super::*; +use crate::chatwidget::tests::helpers::render_bottom_popup; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; + +#[tokio::test] +async fn resumed_reasoning_without_start_accepts_deltas_and_completion() { + for initial_replay in [false, true] { + for snapshot_item_id in [None, Some("reasoning"), Some("previous")] { + for with_delta in [false, true] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, app.config.cwd.to_path_buf()); + let items = snapshot_item_id + .into_iter() + .map(|id| ThreadItem::Reasoning { + id: id.into(), + summary: vec![ + if id == "previous" { + "**Earlier work**\nEarlier paragraph." + } else { + "**Inspecting**\nSnapshot paragraph." + } + .into(), + ], + content: Vec::new(), + }) + .collect(); + let turns = vec![test_turn("turn", TurnStatus::InProgress, items)]; + if initial_replay { + app.chat_widget.handle_thread_session(session); + app.chat_widget + .replay_thread_turns(turns, ReplayKind::ResumeInitialMessages); + } else { + // Reconnect constructs a fresh store; no start notification was retained. + let store = + ThreadEventStore::new_with_session(/*capacity*/ 1, session, turns); + assert!(store.active_reasoning_item.is_none()); + app.replay_thread_snapshot( + store.snapshot(), + /*resume_restored_queue*/ false, + ); + } + let delta = |turn_id: &str, text: &str| { + ServerNotification::ReasoningSummaryTextDelta( + ReasoningSummaryTextDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.into(), + item_id: "reasoning".into(), + delta: text.into(), + summary_index: 0, + }, + ) + }; + app.chat_widget.handle_server_notification( + delta("old-turn", "Wrong turn"), + /*replay_kind*/ None, + ); + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Wrong turn") + ); + if with_delta { + app.chat_widget.handle_server_notification( + delta("turn", "\n**Running checks**"), + /*replay_kind*/ None, + ); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 80) + .contains("Running checks") + ); + } + app.chat_widget.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: "turn".into(), + completed_at_ms: 0, + item: ThreadItem::Reasoning { + id: "reasoning".into(), + summary: vec![ + "**Inspecting**\nSnapshot paragraph.\nCompleted paragraph.".into(), + ], + content: Vec::new(), + }, + }), + /*replay_kind*/ None, + ); + app.chat_widget.handle_server_notification( + delta("turn", "Stale update"), + /*replay_kind*/ None, + ); + assert!( + !render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Stale update") + ); + let transcript = reasoning_transcript(&mut events); + insta::allow_duplicates! { + if snapshot_item_id == Some("previous") { + insta::assert_snapshot!(transcript, @" + • Earlier paragraph. + + • Snapshot paragraph. + Completed paragraph. + "); + } else { + insta::assert_snapshot!(transcript, @" + • Snapshot paragraph. + Completed paragraph. + "); + } + } + } + } + } +} + +#[tokio::test] +async fn resumed_trailing_reasoning_is_kept_when_no_more_reasoning_arrives() { + for next_item in [false, true] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, app.config.cwd.to_path_buf()); + let store = ThreadEventStore::new_with_session( + /*capacity*/ 1, + session, + vec![test_turn( + "turn", + TurnStatus::InProgress, + vec![ThreadItem::Reasoning { + id: "finished".into(), + summary: vec!["**Inspecting**\nSaved paragraph.".into()], + content: Vec::new(), + }], + )], + ); + app.replay_thread_snapshot(store.snapshot(), /*resume_restored_queue*/ false); + if next_item { + app.chat_widget.handle_server_notification( + ServerNotification::ItemStarted( + codex_app_server_protocol::ItemStartedNotification { + thread_id: thread_id.to_string(), + turn_id: "turn".into(), + started_at_ms: 0, + item: ThreadItem::Reasoning { + id: "next".into(), + summary: Vec::new(), + content: Vec::new(), + }, + }, + ), + /*replay_kind*/ None, + ); + } + app.chat_widget.handle_server_notification( + turn_completed_notification(thread_id, "turn", TurnStatus::Completed), + /*replay_kind*/ None, + ); + insta::allow_duplicates! { + insta::assert_snapshot!(reasoning_transcript(&mut events), @" + • Saved paragraph. + "); + } + } +} + +fn reasoning_transcript(events: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { + std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) + if cell + .as_any() + .is::() => + { + Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::>() + .join("\n\n") +} diff --git a/codex-rs/tui/src/app/tests/recap_generation_tests.rs b/codex-rs/tui/src/app/tests/recap_generation_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..12b2532834952ca8b0b101517df255315efc4be9 --- /dev/null +++ b/codex-rs/tui/src/app/tests/recap_generation_tests.rs @@ -0,0 +1,548 @@ +use super::*; +use crate::app::session_lifecycle::ThreadAttachPresentation; +use crate::app::tests::session_lifecycle_requests::recorded_params; +use crate::app::tests::session_lifecycle_requests::start_recording_remote_app_server; +use crate::app_event::RecapTrigger; +use codex_model_provider_info::ModelProviderInfo; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use serde_json::Value; + +const MODEL: &str = "gpt-5.2"; +const MODEL_PROVIDER_ID: &str = "recap-generation-test"; + +fn render_chat_widget(app: &App) -> String { + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24, + ); + let mut buffer = Buffer::empty(area); + app.chat_widget.render(area, &mut buffer); + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") +} + +async fn prepare_eligible_recap(app: &mut App, thread_id: ThreadId) { + app.active_thread_id = Some(thread_id); + app.transcript_cells + .push(Arc::new(crate::history_cell::UserHistoryCell { + message: "Finish the recap implementation".to_string(), + spoken: false, + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + })); + let ready_at = Instant::now(); + app.recap.note_focus_lost(ready_at); + for _ in 0..3 { + app.recap + .note_turn_finished(&TurnStatus::Completed, ready_at); + } + // Advance the scheduler's clock instead of subtracting from the host's uptime. + tokio::time::pause(); + tokio::time::advance(recap::RECAP_DELAY).await; + tokio::time::resume(); +} + +#[tokio::test] +async fn recap_generation_uses_bounded_structured_request_and_inserts_result() -> Result<()> { + let chunks = [ + ev_response_created("recap-response"), + ev_assistant_message( + "recap-message", + r#"{"summary":"Finished parsing.","next_action":"Run focused tests."}"#, + ), + ev_completed("recap-response"), + ] + .into_iter() + .map(|event| StreamingSseChunk { + gate: None, + body: responses::sse(vec![event]), + }) + .collect(); + let (model_server, _completions) = start_streaming_sse_server(vec![chunks]).await; + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "{MODEL}" +model_provider = "{MODEL_PROVIDER_ID}" + +[model_providers.{MODEL_PROVIDER_ID}] +name = "Recap generation test" +base_url = "{}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"#, + model_server.uri() + ), + )?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); + app.config.model = Some(MODEL.to_string()); + app.config.model_provider_id = MODEL_PROVIDER_ID.to_string(); + app.config.model_provider = ModelProviderInfo { + name: "Recap generation test".to_string(), + base_url: Some(format!("{}/v1", model_server.uri())), + request_max_retries: Some(0), + stream_max_retries: Some(0), + ..ModelProviderInfo::default() + }; + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started, + ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + + prepare_eligible_recap(&mut app, thread_id).await; + app.transcript_cells + .push(Arc::new(crate::history_cell::AgentMarkdownCell::new( + format!( + "Parser fix implemented. {} What should empty input do?", + "progress ".repeat(/*n*/ 5_000) + ), + std::path::Path::new("."), + ))); + app.transcript_cells + .push(Arc::new(crate::history_cell::UserHistoryCell { + message: "Keep follow-up work queued.".to_string(), + spoken: false, + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + })); + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CheckRecap { thread_id }, + ) + .await?; + let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) + .await? + .expect("recap start event"); + assert!(matches!(started_event, AppEvent::RecapStarted { .. })); + app.handle_event(&mut tui, &mut app_server, started_event) + .await?; + + let generated_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + loop { + tokio::select! { + event = app_event_rx.recv() => { + let event = event.expect("app event stream"); + if matches!(event, AppEvent::RecapGenerated { .. }) { + break Ok::<_, color_eyre::Report>(event); + } + app.handle_event(&mut tui, &mut app_server, event).await?; + } + event = app_server.next_event() => { + let event = event.expect("app-server event stream"); + app.handle_app_server_event(&app_server, event).await; + } + } + } + }) + .await??; + app.handle_event(&mut tui, &mut app_server, generated_event) + .await?; + + let recap = app + .transcript_cells + .last() + .and_then(|cell| { + cell.as_any() + .downcast_ref::() + }) + .expect("recap history cell"); + assert_eq!( + recap + .raw_lines() + .iter() + .map(ToString::to_string) + .collect::>(), + vec![ + "Conversation recap", + "Finished parsing.", + "Next: Run focused tests.", + ] + ); + + let model_requests = model_server.requests().await; + assert_eq!(model_requests.len(), 1); + let request: Value = serde_json::from_slice(&model_requests[0])?; + let prompt = request["input"] + .as_array() + .and_then(|items| items.iter().rfind(|item| item["role"] == "user")) + .and_then(|item| item["content"].as_array()) + .and_then(|content| content.iter().find(|item| item["type"] == "input_text")) + .and_then(|item| item["text"].as_str()) + .expect("recap prompt"); + assert!( + prompt.contains("Finish the recap implementation"), + "prompt: {prompt}\nrequest: {request}" + ); + assert!(prompt.len() <= recap::RECAP_PROMPT_MAX_BYTES); + assert!(prompt.contains("Assistant: Parser fix implemented.")); + assert!(prompt.contains("What should empty input do?")); + assert!(prompt.contains("[... excerpted ...]")); + assert_eq!( + prompt + .matches("Pending user request: Keep follow-up work queued.") + .count(), + 1 + ); + assert_eq!(request["text"]["format"]["type"], "json_schema"); + assert_eq!( + request["text"]["format"]["schema"]["required"], + serde_json::json!(["summary", "next_action"]) + ); + assert_eq!(request["tools"], serde_json::json!([])); + + app_server.shutdown().await?; + model_server.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn manual_recap_works_when_auto_recap_disabled() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.local_settings.tui.auto_recap = false; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.transcript_cells + .push(Arc::new(crate::history_cell::UserHistoryCell { + message: "Summarize this conversation".to_string(), + spoken: false, + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + })); + let (mut app_server, _requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + // An automatic request must not occupy the slot needed by a manual request. + app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::GenerateRecap { thread_id }, + ) + .await?; + assert!( + render_chat_widget(&app).contains("Generating conversation recap…"), + "manual recap should show progress while generation is in flight" + ); + let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) + .await? + .expect("manual recap start event"); + let (request, temporary_thread_id) = match &started_event { + AppEvent::RecapStarted { + thread_id: requested_thread_id, + request_id, + trigger, + completed_turn_count, + turn_revision, + result: Ok(temporary_thread_id), + .. + } if *requested_thread_id == thread_id && *trigger == RecapTrigger::Manual => ( + recap::RecapRequest { + thread_id: *requested_thread_id, + request_id: *request_id, + trigger: *trigger, + completed_turn_count: *completed_turn_count, + turn_revision: *turn_revision, + }, + ThreadId::from_string(temporary_thread_id).expect("temporary recap thread ID"), + ), + other => panic!("expected a started manual recap, got {other:?}"), + }; + + app.handle_event(&mut tui, &mut app_server, started_event) + .await?; + + assert_eq!(app.temporary_structured_requests.len(), 1); + assert!( + app.handle_generated_recap( + request, + temporary_thread_id, + Err("temporary failure".to_string()), + ) + .is_none() + ); + assert!( + !render_chat_widget(&app).contains("Generating conversation recap…"), + "manual recap progress should clear when generation finishes" + ); + + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected visible manual recap error, got {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + rendered.contains("Could not generate a recap. Please try again."), + "rendered error: {rendered:?}" + ); + + // A completion quarantined during reconnect must not leave the next recap stuck as busy. + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:1".into(), + auth_token: None, + }, + }; + app.request_recap(&app_server, thread_id, RecapTrigger::Manual); + let stale = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) + .await? + .expect("recap completion before disconnect"); + assert!(matches!(stale, AppEvent::RecapStarted { .. })); + assert!(app.begin_reconnect()); + app.handle_event(&mut tui, &mut app_server, stale).await?; + while app_event_rx.try_recv().is_ok() {} + app.reconnect.offline = false; + app.request_recap(&app_server, thread_id, RecapTrigger::Manual); + let next = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()).await?; + assert!( + matches!(next, Some(AppEvent::RecapStarted { .. })), + "{next:?}" + ); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn auto_recap_opt_out_blocks_requests_and_cleans_up_pending_start() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + prepare_eligible_recap(&mut app, thread_id).await; + let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.local_settings.tui.auto_recap = false; + app.schedule_recap_check(thread_id, Instant::now()); + app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CheckRecap { thread_id }, + ) + .await?; + tokio::task::yield_now().await; + + assert!(app_event_rx.try_recv().is_err()); + assert_eq!( + recorded_params(&requests, "thread/start"), + Vec::::new() + ); + let rendered = app + .transcript_cells + .iter() + .flat_map(|cell| cell.display_lines(/*width*/ 80)) + .collect::>(); + + app.local_settings.tui.auto_recap = true; + app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); + let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) + .await? + .expect("recap start event"); + assert!(matches!(started_event, AppEvent::RecapStarted { .. })); + app.local_settings.tui.auto_recap = false; + app.handle_event(&mut tui, &mut app_server, started_event) + .await?; + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + while recorded_params(&requests, "thread/unsubscribe").is_empty() { + tokio::task::yield_now().await; + } + }) + .await?; + + // Finish the unsubscribe round trip before shutting down the recording proxy. + app_server + .thread_loaded_list(codex_app_server_protocol::ThreadLoadedListParams { + cursor: None, + limit: None, + }) + .await?; + assert_eq!( + recorded_params(&requests, "turn/start"), + Vec::::new() + ); + assert!(app.temporary_structured_requests.is_empty()); + app_server.shutdown().await?; + proxy.await??; + insta::assert_snapshot!( + "auto_recap_disabled_transcript", + lines_to_single_string(&rendered) + ); + Ok(()) +} + +#[tokio::test] +async fn recap_generation_uses_remote_workspace_cwd() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let remote_cwd = if cfg!(windows) { + PathBuf::from(r"C:\remote\project") + } else { + PathBuf::from("/srv/remote/project") + }; + let (app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let mut app_server = app_server.with_remote_cwd_override(Some(remote_cwd.clone())); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.transcript_cells + .push(Arc::new(crate::history_cell::UserHistoryCell { + message: "Finish the recap implementation".to_string(), + spoken: false, + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + })); + + app.request_recap(&app_server, ThreadId::new(), RecapTrigger::Automatic); + let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) + .await? + .expect("recap start event"); + app.handle_event(&mut tui, &mut app_server, started_event) + .await?; + + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1); + assert_eq!(starts[0]["cwd"], remote_cwd.to_string_lossy().into_owned()); + assert_eq!(starts[0]["ephemeral"], true); + assert_eq!(starts[0]["environments"], serde_json::json!([])); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn temporary_recap_threads_disable_memories_and_remote_mcp_servers() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + std::fs::write( + codex_home.path().join("config.toml"), + "features.context_management.experimental_mode = true\n[mcp_servers.filesystem]\ncommand = 'true'\n", + )?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); + let (app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + + let config = app.chat_widget.config_ref(); + let options = crate::temporary_structured_request::TemporaryStructuredThreadOptions { + model: app.chat_widget.current_model().to_string(), + model_provider: config.model_provider_id.clone(), + cwd: config.cwd.display().to_string(), + active_permission_profile: config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + mcp_server_names: Vec::new(), + }; + crate::temporary_structured_request::start_temporary_thread( + &app_server.request_handle(), + options, + ) + .await?; + + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1); + assert_eq!(starts[0]["config"]["features.memories"], false); + assert_eq!(starts[0]["config"]["features.context_management"], false); + assert!(starts[0]["config"].get("features.memory_tool").is_none()); + assert_eq!( + starts[0]["config"]["mcp_servers"]["filesystem"]["enabled"], + false + ); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn recap_check_rejects_a_non_displayed_thread() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let displayed_thread_id = ThreadId::new(); + prepare_eligible_recap(&mut app, displayed_thread_id).await; + while app_event_rx.try_recv().is_ok() {} + let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CheckRecap { + thread_id: ThreadId::new(), + }, + ) + .await?; + tokio::task::yield_now().await; + + assert!(app_event_rx.try_recv().is_err()); + assert_eq!( + recorded_params(&requests, "thread/start"), + Vec::::new() + ); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn recap_check_rejects_a_running_turn() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + prepare_eligible_recap(&mut app, thread_id).await; + app.chat_widget + .handle_thread_session(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + app.chat_widget.handle_server_notification( + turn_started_notification(thread_id, "active-turn"), + /*replay_kind*/ None, + ); + while app_event_rx.try_recv().is_ok() {} + let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::CheckRecap { thread_id }, + ) + .await?; + tokio::task::yield_now().await; + + assert!(app_event_rx.try_recv().is_err()); + assert_eq!( + recorded_params(&requests, "thread/start"), + Vec::::new() + ); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs new file mode 100644 index 0000000000000000000000000000000000000000..18ca7678e6c1434a8a63fc925944062e97d4ddd9 --- /dev/null +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -0,0 +1,1158 @@ +use super::*; +use crate::app::safety_buffering::SafetyBufferedRetry; +use crate::app::session_lifecycle::ThreadAttachPresentation; +use crate::chatwidget::UserMessage; +use crate::chatwidget::tests::helpers::normalize_completion_timestamps; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification; +use codex_model_provider_info::ModelProviderInfo; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::openai_models::ToolMode; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::test_support::PathExt; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::ev_response_created; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::StreamingSseServer; +use core_test_support::streaming_sse::start_streaming_sse_server; +use pretty_assertions::assert_eq; +use serde_json::Value; +use tokio::sync::oneshot; + +const CURRENT_MODEL: &str = "gpt-5.6-terra"; +const FASTER_MODEL: &str = "gpt-5.6-luna"; +const MODEL_PROVIDER_ID: &str = "safety-retry-test"; +const PREVIOUS_PROMPT: &str = "Establish context"; +const RETRY_PROMPT: &str = "Handle the safety-buffered request"; +const COMMITTED_STEER: &str = "Keep the accepted steer"; +const UNSENT_DRAFT: &str = "Keep this unsent draft"; +const RETRY_GOAL: &str = "Preserve this goal across the retry"; +const SAFETY_RETRY_THREAD_NAME: &str = "Safety retry source"; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SafetyRetryScenario { + Once, + RetryTwice, + InterruptedPrevious, + UnsupportedPermissions, +} + +fn response_chunks(response_id: &str) -> Vec { + [ + ev_response_created(response_id), + ev_assistant_message(&format!("message-{response_id}"), "done"), + ev_completed(response_id), + ] + .into_iter() + .map(|event| StreamingSseChunk { + gate: None, + body: responses::sse(vec![event]), + }) + .collect() +} + +fn gated_response_chunks( + response_id: &str, + completed: Value, +) -> (Vec, oneshot::Sender<()>) { + let (release_tx, release_rx) = oneshot::channel(); + ( + vec![ + StreamingSseChunk { + gate: None, + body: responses::sse(vec![ev_response_created(response_id)]), + }, + StreamingSseChunk { + gate: Some(release_rx), + body: responses::sse(vec![completed]), + }, + ], + release_tx, + ) +} + +fn next_user_turn_event( + app_event_rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> AppCommand { + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::CodexOp(turn @ AppCommand::UserTurn { .. }) = event { + return turn; + } + } + panic!("expected UserTurn app event"); +} + +fn submit_prompt(app: &mut App, prompt: &str) { + app.chat_widget.apply_external_edit(prompt.to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); +} + +fn drain_active_thread_events(app: &mut App) { + while let Some(event) = app + .active_thread_rx + .as_mut() + .and_then(|receiver| receiver.try_recv().ok()) + { + app.handle_thread_event_now(event); + } +} + +async fn next_turn_started( + app: &mut App, + app_server: &mut AppServerSession, + thread_id: ThreadId, +) -> String { + loop { + let event = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 5), + app_server.next_event(), + ) + .await + .expect("app-server should emit a turn/start event") + .expect("app-server event stream should remain open"); + let started_turn_id = if let AppServerEvent::ServerNotification(notification) = &event + && let ServerNotification::TurnStarted(notification) = notification.as_ref() + && notification.thread_id == thread_id.to_string() + { + Some(notification.turn.id.clone()) + } else { + None + }; + app.handle_app_server_event(app_server, event).await; + drain_active_thread_events(app); + if let Some(turn_id) = started_turn_id { + return turn_id; + } + } +} + +async fn wait_for_turn_completed( + app: &mut App, + app_server: &mut AppServerSession, + thread_id: ThreadId, +) { + loop { + let event = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 5), + app_server.next_event(), + ) + .await + .expect("app-server should emit a turn/completed event") + .expect("app-server event stream should remain open"); + let completed = matches!( + &event, + AppServerEvent::ServerNotification(notification) + if matches!( + notification.as_ref(), + ServerNotification::TurnCompleted(notification) + if notification.thread_id == thread_id.to_string() + ) + ); + app.handle_app_server_event(app_server, event).await; + drain_active_thread_events(app); + if completed { + return; + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn active_turn_interrupt_is_nonblocking_and_coalesces_repeated_requests() -> Result<()> { + Box::pin(interrupt_after_inactive_steer(SteerSwitch::WithinTask)).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn agents_overview_acknowledges_inactive_steer_before_interrupt() -> Result<()> { + Box::pin(interrupt_after_inactive_steer(SteerSwitch::BetweenTasks)).await +} + +enum SteerSwitch { + WithinTask, + BetweenTasks, +} + +async fn interrupt_after_inactive_steer(switch: SteerSwitch) -> Result<()> { + let via_overview = matches!(switch, SteerSwitch::BetweenTasks); + let (chunks, release_response) = + gated_response_chunks("interrupt-response", ev_completed("interrupt-response")); + let (steered_chunks, release_steered_response) = + gated_response_chunks("steered-response", ev_completed("steered-response")); + let (server, _completions) = start_streaming_sse_server(vec![chunks, steered_chunks]).await; + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "{CURRENT_MODEL}" +model_provider = "{MODEL_PROVIDER_ID}" + +[model_providers.{MODEL_PROVIDER_ID}] +name = "Interrupt test" +base_url = "{}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"#, + server.uri() + ), + )?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); + app.config.model = Some(CURRENT_MODEL.to_string()); + app.config.model_provider_id = MODEL_PROVIDER_ID.to_string(); + app.config.model_provider = ModelProviderInfo { + name: "Interrupt test".to_string(), + base_url: Some(format!("{}/v1", server.uri())), + request_max_retries: Some(0), + stream_max_retries: Some(0), + ..ModelProviderInfo::default() + }; + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started, + ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + + submit_prompt(&mut app, COMMITTED_STEER); + let turn = next_user_turn_event(&mut app_event_rx); + app.submit_thread_op(&mut app_server, thread_id, turn) + .await?; + let turn_id = next_turn_started(&mut app, &mut app_server, thread_id).await; + // Leave the original message's completion queued until after submitting the identical steer. + tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 5), + server.wait_for_request_count(/*count*/ 1), + ) + .await?; + + let acknowledged_input = app.chat_widget.capture_thread_input_state(); + submit_prompt(&mut app, COMMITTED_STEER); + let steer = next_user_turn_event(&mut app_event_rx); + let AppCommand::UserTurn { + client_user_message_id, + .. + } = &steer + else { + unreachable!("user turn"); + }; + let expected_client_id = client_user_message_id.clone(); + let pending_input = app.chat_widget.capture_thread_input_state(); + app.submit_thread_op(&mut app_server, thread_id, steer) + .await?; + let other_id = ThreadId::from_string( + &app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Other task", + Some(MODEL_PROVIDER_ID), + /*git_info*/ None, + ) + .expect("materialize other task"), + )?; + let other = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + other_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await?; + for cwd in [&app.config.cwd, &other.session.cwd] { + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + cwd.as_path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + } + app.thread_event_channels.insert( + other_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + other.session, + other.turns, + ), + ); + // Receive the real commit while another thread is visible, with no replay capacity. + if via_overview { + Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, other_id)).await?; + } else { + Box::pin(app.select_agent_thread(&mut tui, &mut app_server, other_id)).await?; + app.thread_event_channels[&thread_id] + .store + .lock() + .await + .capacity = 0; + } + assert_eq!(app.current_displayed_thread_id(), Some(other_id)); + let mut release_response = Some(release_response); + loop { + let event = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 5), + app_server.next_event(), + ) + .await? + .expect("app-server remains open"); + let committed_id = if let AppServerEvent::ServerNotification(notification) = &event + && let ServerNotification::ItemCompleted(item) = notification.as_ref() + && let ThreadItem::UserMessage { + content, client_id, .. + } = &item.item + && ChatWidget::user_message_display_from_inputs(content).message == COMMITTED_STEER + { + Some(client_id.clone().expect("receipt echoes the submission ID")) + } else { + None + }; + app.handle_app_server_event(&app_server, event).await; + if let Some(client_id) = committed_id { + let saved_input = if via_overview { + assert!(!app.thread_event_channels.contains_key(&thread_id)); + app.agents_overview.input_states.get(&thread_id).cloned() + } else { + let store = app.thread_event_channels[&thread_id].store.lock().await; + assert!(store.buffer.is_empty()); + store.input_state.clone() + }; + if let Some(release_response) = release_response.take() { + assert_ne!(client_id, expected_client_id); + assert_eq!(saved_input, pending_input); + let _ = release_response.send(()); + } else { + assert_eq!(client_id, expected_client_id); + assert_eq!(saved_input, acknowledged_input); + break; + } + } + } + if via_overview { + Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id)).await?; + } else { + Box::pin(app.select_agent_thread(&mut tui, &mut app_server, thread_id)).await?; + } + assert_eq!(app.active_thread_id, Some(thread_id)); + + let interrupt = AppCommand::interrupt(); + assert!( + app.try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &interrupt) + .await? + ); + let AppServerRequestId::Integer(next_request_id) = app_server.next_request_id() else { + unreachable!("embedded app-server request IDs are integers"); + }; + assert!( + app.try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &interrupt) + .await? + ); + assert_eq!( + app_server.next_request_id(), + AppServerRequestId::Integer(next_request_id + 1) + ); + { + let store = app.thread_event_channels[&thread_id].store.lock().await; + assert_eq!(store.active_turn_id.as_deref(), Some(turn_id.as_str())); + assert_eq!( + store.pending_interrupt_turn_id.as_deref(), + Some(turn_id.as_str()) + ); + } + + while app_event_rx.try_recv().is_ok() {} + app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new( + ServerNotification::Warning(WarningNotification { + thread_id: Some(thread_id.to_string()), + message: "event handled while interrupt is pending".to_string(), + }), + ))); + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::InsertHistoryCell(_)) + )); + + wait_for_turn_completed(&mut app, &mut app_server, thread_id).await; + assert_eq!( + app.thread_event_channels[&thread_id] + .store + .lock() + .await + .pending_interrupt_turn_id, + None + ); + + insta::assert_snapshot!(app.chat_widget.composer_text_with_pending(), @""); + assert!( + std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .all(|event| !matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. }))) + ); + let source = app_server + .thread_read(thread_id, /*include_turns*/ true) + .await?; + assert_eq!(user_message_count(&source, COMMITTED_STEER), 2); + let _ = release_steered_response.send(()); + app_server.shutdown().await?; + server.shutdown().await; + Ok(()) +} + +async fn drive_until_request_count( + app: &mut App, + app_server: &mut AppServerSession, + server: &StreamingSseServer, + expected_request_count: usize, +) { + let timeout = tokio::time::sleep(std::time::Duration::from_secs(/*secs*/ 5)); + tokio::pin!(timeout); + loop { + tokio::select! { + () = server.wait_for_request_count(expected_request_count) => return, + event = app_server.next_event() => { + let event = event.expect("app-server event stream should remain open"); + app.handle_app_server_event(app_server, event).await; + drain_active_thread_events(app); + } + () = &mut timeout => { + panic!("expected {expected_request_count} Responses API requests"); + } + } + } +} + +fn user_input_texts(body: &Value) -> Vec { + body.get("input") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("message")) + .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text")) + .filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned)) + .collect() +} + +fn user_message_count(thread: &Thread, prompt: &str) -> usize { + thread + .turns + .iter() + .flat_map(|turn| &turn.items) + .filter_map(|item| match item { + ThreadItem::UserMessage { content, .. } => Some(content), + _ => None, + }) + .filter(|content| { + content + .iter() + .filter_map(|item| match item { + AppServerUserInput::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::() + == prompt + }) + .count() +} + +async fn run_safety_retry( + previous_prompt: Option<&str>, + failing_draft: Option<&str>, + committed_steer: Option<&str>, + scenario: SafetyRetryScenario, +) -> Result<()> { + let (active_chunks, release_active_response) = gated_response_chunks( + "active-response", + ev_completed_with_tokens("active-response", /*total_tokens*/ 100), + ); + let mut release_active_response = Some(release_active_response); + let (steered_chunks, release_steered_response) = + gated_response_chunks("steered-response", ev_completed("steered-response")); + let (previous_chunks, release_previous_response) = + gated_response_chunks("previous-response", ev_completed("previous-response")); + let (retry_chunks, release_retry_response) = + gated_response_chunks("retry-response", ev_completed("retry-response")); + let mut response_sequences = Vec::new(); + if previous_prompt.is_some() { + if scenario == SafetyRetryScenario::InterruptedPrevious { + response_sequences.push(previous_chunks); + } else { + response_sequences.push(response_chunks("previous-response")); + } + } + response_sequences.push(active_chunks); + if committed_steer.is_some() { + response_sequences.push(steered_chunks); + } + if scenario == SafetyRetryScenario::RetryTwice { + response_sequences.push(retry_chunks); + response_sequences.push(response_chunks("second-retry-response")); + } else { + response_sequences.push(response_chunks("retry-response")); + } + response_sequences.push(vec![StreamingSseChunk { + gate: None, + body: responses::sse(vec![ + ev_response_created("goal-continuation-response"), + ev_assistant_message("goal-continuation-message", "done"), + ev_completed_with_tokens("goal-continuation-response", /*total_tokens*/ 1_000), + ]), + }]); + let expected_request_count = response_sequences.len(); + let (server, _completions) = start_streaming_sse_server(response_sequences).await; + + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + // Keep text-only retry fixtures independent of the optional Code Mode host. + let mut model_catalog = codex_models_manager::bundled_models_response()?; + for model in model_catalog + .models + .iter_mut() + .filter(|model| matches!(model.slug.as_str(), CURRENT_MODEL | FASTER_MODEL)) + { + model.tool_mode = Some(ToolMode::Direct); + } + let model_catalog_path = codex_home.path().join("models.json"); + std::fs::write(&model_catalog_path, serde_json::to_vec(&model_catalog)?)?; + let model_catalog_path = toml::Value::String(model_catalog_path.display().to_string()); + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "{CURRENT_MODEL}" +model_provider = "{MODEL_PROVIDER_ID}" +model_catalog_json = {model_catalog_path} + +[model_providers.{MODEL_PROVIDER_ID}] +name = "Safety retry test" +base_url = "{}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[features] +goals = true +"#, + server.uri() + ), + )?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); + app.config.model = Some(CURRENT_MODEL.to_string()); + app.config.model_catalog = Some(model_catalog); + app.config.model_provider_id = MODEL_PROVIDER_ID.to_string(); + app.config.model_provider = ModelProviderInfo { + name: "Safety retry test".to_string(), + base_url: Some(format!("{}/v1", server.uri())), + request_max_retries: Some(0), + stream_max_retries: Some(0), + ..ModelProviderInfo::default() + }; + app.config + .features + .enable(Feature::Goals) + .expect("test config should allow goals"); + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut started = app_server.start_thread(&app.config).await?; + let source_thread_id = started.session.thread_id; + // Keep ordered response fixtures focused on safety retries, not background naming. + app_server + .thread_set_name(source_thread_id, SAFETY_RETRY_THREAD_NAME.to_string()) + .await?; + started.session.thread_name = Some(SAFETY_RETRY_THREAD_NAME.to_string()); + app.replace_chat_widget_with_app_server_thread( + &mut tui, + started, + ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + + if let Some(previous_prompt) = previous_prompt { + submit_prompt(&mut app, previous_prompt); + let previous_turn = next_user_turn_event(&mut app_event_rx); + app.submit_thread_op(&mut app_server, source_thread_id, previous_turn) + .await?; + if scenario == SafetyRetryScenario::InterruptedPrevious { + let previous_turn_id = + next_turn_started(&mut app, &mut app_server, source_thread_id).await; + drive_until_request_count( + &mut app, + &mut app_server, + &server, + /*expected_request_count*/ 1, + ) + .await; + app_server + .turn_interrupt(source_thread_id, previous_turn_id) + .await?; + } else { + wait_for_turn_completed(&mut app, &mut app_server, source_thread_id).await; + } + } + + let state_db = codex_state::StateRuntime::init( + app.config.sqlite.clone(), + app.config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + state_db + .thread_goals() + .replace_thread_goal( + source_thread_id, + RETRY_GOAL, + codex_state::ThreadGoalStatus::Active, + /*token_budget*/ Some(1_000), + ) + .await + .expect("source goal should be stored"); + let source_goal_id = state_db + .thread_goals() + .get_thread_goal(source_thread_id) + .await + .expect("source goal should be readable") + .expect("source goal") + .goal_id; + state_db + .thread_goals() + .account_thread_goal_usage( + source_thread_id, + /*time_delta_seconds*/ 12, + /*token_delta*/ 50, + codex_state::GoalAccountingMode::ActiveOrStopped, + Some(source_goal_id.as_str()), + ) + .await + .expect("source goal usage should be recorded"); + + submit_prompt(&mut app, RETRY_PROMPT); + let mut active_turn = next_user_turn_event(&mut app_event_rx); + app.submit_thread_op(&mut app_server, source_thread_id, active_turn.clone()) + .await?; + let active_turn_id = next_turn_started(&mut app, &mut app_server, source_thread_id).await; + drive_until_request_count( + &mut app, + &mut app_server, + &server, + usize::from(previous_prompt.is_some()) + 1, + ) + .await; + + if let Some(committed_steer) = committed_steer { + submit_prompt(&mut app, committed_steer); + let steer = next_user_turn_event(&mut app_event_rx); + app.submit_thread_op(&mut app_server, source_thread_id, steer) + .await?; + let _ = release_active_response + .take() + .expect("active response should still be gated") + .send(()); + drive_until_request_count( + &mut app, + &mut app_server, + &server, + usize::from(previous_prompt.is_some()) + 2, + ) + .await; + let source = app_server + .thread_read(source_thread_id, /*include_turns*/ true) + .await?; + assert_eq!(user_message_count(&source, committed_steer), 1); + } + + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new( + ServerNotification::ModelSafetyBufferingUpdated( + ModelSafetyBufferingUpdatedNotification { + thread_id: source_thread_id.to_string(), + turn_id: active_turn_id.clone(), + model: CURRENT_MODEL.to_string(), + use_cases: Vec::new(), + reasons: Vec::new(), + show_buffering_ui: true, + faster_model: Some(FASTER_MODEL.to_string()), + }, + ), + )), + ) + .await; + drain_active_thread_events(&mut app); + assert!( + app.chat_widget + .can_retry_safety_buffered_turn(&active_turn_id) + ); + if let Some(draft) = failing_draft { + app.chat_widget.apply_external_edit(draft.to_string()); + let source = app_server + .thread_read(source_thread_id, /*include_turns*/ true) + .await?; + std::fs::remove_file( + source + .path + .expect("source thread should have a rollout path"), + )?; + } + + let primary_thread_id = ThreadId::new(); + app.primary_thread_id = Some(primary_thread_id); + Box::pin(app.retry_safety_buffered_turn( + &mut tui, + &mut app_server, + SafetyBufferedRetry { + thread_id: source_thread_id, + turn_id: active_turn_id.clone(), + model: FASTER_MODEL.to_string(), + turn: active_turn.clone(), + prompt: UserMessage::from(RETRY_PROMPT), + }, + )) + .await; + + assert_eq!(app.primary_thread_id, Some(primary_thread_id)); + assert_eq!(app.active_thread_id, Some(source_thread_id)); + assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id)); + app.primary_thread_id = Some(source_thread_id); + while app_event_rx.try_recv().is_ok() {} + + if scenario == SafetyRetryScenario::UnsupportedPermissions { + let extra_root = + AbsolutePathBuf::resolve_path_against_base("extra", app.config.cwd.as_path()); + let permission_profile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: extra_root.into(), + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + ], + glob_scan_max_depth: None, + }, + }; + app.config + .permissions + .set_permission_profile(permission_profile.clone())?; + app.chat_widget.set_permission_profile_with_active_profile( + permission_profile, + /*active_permission_profile*/ None, + )?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + if let AppCommand::UserTurn { + active_permission_profile, + .. + } = &mut active_turn + { + *active_permission_profile = None; + } + } + + Box::pin(app.retry_safety_buffered_turn( + &mut tui, + &mut app_server, + SafetyBufferedRetry { + thread_id: source_thread_id, + turn_id: active_turn_id.clone(), + model: FASTER_MODEL.to_string(), + turn: active_turn, + prompt: UserMessage::from(RETRY_PROMPT), + }, + )) + .await; + + if scenario == SafetyRetryScenario::UnsupportedPermissions { + assert_eq!(app.active_thread_id, Some(source_thread_id)); + assert_eq!(app.primary_thread_id, Some(source_thread_id)); + assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id)); + assert!( + app.chat_widget + .can_retry_safety_buffered_turn(&active_turn_id) + ); + let source = app_server + .thread_read(source_thread_id, /*include_turns*/ true) + .await?; + assert_eq!( + source.turns.last().map(|turn| &turn.status), + Some(&TurnStatus::InProgress) + ); + let error_cell = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .expect("unsupported permissions should be added to history"); + insta::assert_snapshot!( + lines_to_single_string(&error_cell.display_lines(/*width*/ 200)), + @"■ Failed to retry with a faster model: the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile" + ); + if let Some(release_active_response) = release_active_response.take() { + let _ = release_active_response.send(()); + } + let _ = release_steered_response.send(()); + let _ = release_previous_response.send(()); + let _ = release_retry_response.send(()); + app_server.shutdown().await?; + server.shutdown().await; + return Ok(()); + } + + let first_retry_thread_id = app.chat_widget.thread_id().expect("first retry thread id"); + if scenario == SafetyRetryScenario::RetryTwice { + let first_retry_turn_id = + next_turn_started(&mut app, &mut app_server, first_retry_thread_id).await; + drive_until_request_count( + &mut app, + &mut app_server, + &server, + /*expected_request_count*/ 2, + ) + .await; + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new( + ServerNotification::ModelSafetyBufferingUpdated( + ModelSafetyBufferingUpdatedNotification { + thread_id: first_retry_thread_id.to_string(), + turn_id: first_retry_turn_id.clone(), + model: FASTER_MODEL.to_string(), + use_cases: Vec::new(), + reasons: Vec::new(), + show_buffering_ui: true, + faster_model: Some(FASTER_MODEL.to_string()), + }, + ), + )), + ) + .await; + drain_active_thread_events(&mut app); + assert!( + app.chat_widget + .can_retry_safety_buffered_turn(&first_retry_turn_id) + ); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let second_retry = loop { + match app_event_rx.try_recv() { + Ok(event @ AppEvent::ConfirmSafetyBufferedRetry { .. }) => { + Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + } + Ok(AppEvent::RetrySafetyBufferedTurn { + thread_id, + turn_id, + model, + turn, + prompt, + }) => { + break SafetyBufferedRetry { + thread_id, + turn_id, + model, + turn, + prompt, + }; + } + Ok(_) => continue, + Err(err) => panic!("expected second safety-buffering retry event: {err}"), + } + }; + let AppCommand::UserTurn { items, .. } = &second_retry.turn else { + panic!("second safety-buffering retry should retain the user turn"); + }; + assert!(items.iter().any( + |item| matches!(item, AppServerUserInput::Text { text, .. } if text == RETRY_PROMPT) + )); + Box::pin(app.retry_safety_buffered_turn(&mut tui, &mut app_server, second_retry)).await; + } + + if let Some(draft) = failing_draft { + assert_eq!( + app.chat_widget.composer_text_with_pending(), + format!("{RETRY_PROMPT}\n{draft}") + ); + assert_eq!(app.chat_widget.thread_id(), Some(source_thread_id)); + if let Some(release_active_response) = release_active_response.take() { + let _ = release_active_response.send(()); + } + let _ = release_steered_response.send(()); + app_server.shutdown().await?; + server.shutdown().await; + return Ok(()); + } + + drive_until_request_count(&mut app, &mut app_server, &server, expected_request_count).await; + let mut replayed_history = String::new(); + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let rendered = lines_to_single_string(&cell.transcript_lines(/*width*/ 80)); + if cell + .as_any() + .is::() + { + replayed_history.push_str(&normalize_completion_timestamps(rendered)); + } else { + replayed_history.push_str(&rendered); + } + } + } + assert_eq!( + replayed_history.contains("Conversation interrupted"), + scenario == SafetyRetryScenario::InterruptedPrevious + ); + let forked_history = replayed_history + .rsplit_once("Thread forked from") + .expect("safety retry should render fork lineage") + .1; + assert_eq!( + forked_history.matches(RETRY_PROMPT).count(), + 1, + "the fork should render the retried prompt once: {forked_history}" + ); + if committed_steer.is_some() { + let rendered_retry = forked_history + .lines() + .skip_while(|line| !line.contains(RETRY_PROMPT)) + .collect::>() + .join("\n"); + insta::assert_snapshot!("safety_retry_committed_steer_history", rendered_retry); + } + + let retry_thread_id = app.chat_widget.thread_id().expect("retry thread id"); + let source = app_server + .thread_read(source_thread_id, /*include_turns*/ true) + .await?; + let retry = app_server + .thread_read(retry_thread_id, /*include_turns*/ true) + .await?; + assert_ne!(retry_thread_id, source_thread_id); + assert_eq!( + source.turns.last().map(|turn| &turn.status), + Some(&TurnStatus::Interrupted) + ); + assert_eq!( + retry.forked_from_id.as_deref(), + Some( + if scenario == SafetyRetryScenario::RetryTwice { + first_retry_thread_id + } else { + source_thread_id + } + .to_string() + ) + .as_deref() + ); + let expected_retry_prompt = match committed_steer { + Some(committed_steer) => format!("{RETRY_PROMPT}\n{committed_steer}"), + None => RETRY_PROMPT.to_string(), + }; + assert_eq!(user_message_count(&source, RETRY_PROMPT), 1); + assert_eq!(user_message_count(&retry, &expected_retry_prompt), 1); + if let Some(committed_steer) = committed_steer { + assert_eq!(user_message_count(&source, committed_steer), 1); + } + if let Some(previous_prompt) = previous_prompt { + assert_eq!(user_message_count(&source, previous_prompt), 1); + assert_eq!(user_message_count(&retry, previous_prompt), 1); + } + + let source_goal = app_server + .thread_goal_get(source_thread_id) + .await? + .goal + .expect("source goal"); + let retry_goal = app_server + .thread_goal_get(retry_thread_id) + .await? + .goal + .expect("retry goal"); + let expected_source_tokens = if committed_steer.is_some() { 150 } else { 50 }; + assert_eq!(source_goal.objective, RETRY_GOAL); + assert_eq!(source_goal.tokens_used, expected_source_tokens); + assert!(source_goal.time_used_seconds >= 12); + assert_eq!(retry_goal.objective, RETRY_GOAL); + assert!(retry_goal.tokens_used >= expected_source_tokens); + assert!(retry_goal.time_used_seconds >= source_goal.time_used_seconds); + + let request_bodies = server + .requests() + .await + .iter() + .map(|request| serde_json::from_slice::(request)) + .collect::>>()?; + let retry_request_index = + usize::from(previous_prompt.is_some()) + usize::from(committed_steer.is_some()) + 1; + let retry_request = request_bodies + .get(retry_request_index) + .expect("retry should issue a Responses API request"); + assert_eq!(retry_request["model"].as_str(), Some(FASTER_MODEL)); + assert_eq!(retry_request["reasoning"]["effort"].as_str(), Some("low")); + let relevant_prompts = user_input_texts(retry_request) + .into_iter() + .filter(|text| { + text == PREVIOUS_PROMPT + || text == RETRY_PROMPT + || committed_steer.is_some_and(|steer| text == steer) + }) + .collect::>(); + let mut expected_prompts = match previous_prompt { + Some(previous_prompt) => vec![previous_prompt.to_string(), RETRY_PROMPT.to_string()], + None => vec![RETRY_PROMPT.to_string()], + }; + expected_prompts.extend(committed_steer.map(str::to_string)); + assert_eq!(relevant_prompts, expected_prompts); + if previous_prompt.is_none() { + assert!( + !user_input_texts(retry_request) + .iter() + .any(|text| text.contains("")), + "first-turn safety retry should not inherit an interruption marker" + ); + } + let goal_continuation_request_index = if scenario == SafetyRetryScenario::RetryTwice { + let second_retry_request = request_bodies + .get(retry_request_index + 1) + .expect("second retry should issue a Responses API request"); + assert!( + user_input_texts(second_retry_request) + .iter() + .any(|text| text == RETRY_PROMPT), + "second safety retry should submit the original prompt" + ); + assert!( + !user_input_texts(second_retry_request) + .iter() + .any(|text| text.contains("")), + "second safety retry should not inherit an interruption marker" + ); + retry_request_index + 2 + } else { + retry_request_index + 1 + }; + assert!( + user_input_texts(&request_bodies[goal_continuation_request_index]) + .iter() + .any(|text| text.contains(RETRY_GOAL)), + "inherited goal continuation should resume after the explicit retry" + ); + + if let Some(release_active_response) = release_active_response.take() { + let _ = release_active_response.send(()); + } + let _ = release_steered_response.send(()); + let _ = release_previous_response.send(()); + let _ = release_retry_response.send(()); + app_server.shutdown().await?; + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_forks_after_the_previous_turn_and_uses_faster_settings() -> Result<()> { + run_safety_retry( + Some(PREVIOUS_PROMPT), + /*failing_draft*/ None, + /*committed_steer*/ None, + SafetyRetryScenario::Once, + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_preserves_a_committed_steer_from_the_interrupted_turn() -> Result<()> { + run_safety_retry( + Some(PREVIOUS_PROMPT), + /*failing_draft*/ None, + Some(COMMITTED_STEER), + SafetyRetryScenario::Once, + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_forks_first_turn_and_continues_without_duplicating_prompt() -> Result<()> { + run_safety_retry( + /*previous_prompt*/ None, + /*failing_draft*/ None, + /*committed_steer*/ None, + SafetyRetryScenario::Once, + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_can_retry_a_first_turn_a_second_time() -> Result<()> { + run_safety_retry( + /*previous_prompt*/ None, + /*failing_draft*/ None, + /*committed_steer*/ None, + SafetyRetryScenario::RetryTwice, + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_replays_older_interruption_notices() -> Result<()> { + run_safety_retry( + Some(PREVIOUS_PROMPT), + /*failing_draft*/ None, + /*committed_steer*/ None, + SafetyRetryScenario::InterruptedPrevious, + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_branch_failure_preserves_unsent_draft() -> Result<()> { + run_safety_retry( + Some(PREVIOUS_PROMPT), + Some(UNSENT_DRAFT), + /*committed_steer*/ None, + SafetyRetryScenario::Once, + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn safety_retry_rejects_unsupported_permissions_before_interrupting() -> Result<()> { + run_safety_retry( + /*previous_prompt*/ None, + /*failing_draft*/ None, + /*committed_steer*/ None, + SafetyRetryScenario::UnsupportedPermissions, + ) + .await +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs new file mode 100644 index 0000000000000000000000000000000000000000..a4bf3563cbce0a71d9f5f547ae96ef92f0b0a23d --- /dev/null +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -0,0 +1,4562 @@ +use super::*; +use crate::app_event::TranscriptExportDestination; +use crate::bottom_pane::BottomPaneView; +use app_test_support::create_fake_paginated_rollout; +use app_test_support::create_fake_parented_rollout_with_source; +use app_test_support::create_fake_rollout; +use app_test_support::rollout_path; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadHistoryMode; +use codex_app_server_protocol::ThreadItemsListParams; +use codex_app_server_protocol::ThreadItemsListResponse; +use codex_app_server_protocol::ThreadStatus; +use codex_protocol::AgentPath; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::EnteredReviewModeItem; +use codex_protocol::items::ExitedReviewModeItem; +use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ReviewTarget; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_state::SqliteConfig; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use std::sync::Mutex; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; + +pub(super) type RecordedRequests = Arc>>; +pub(super) type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle>); + +async fn complete_managed_worktree_creation( + app: &mut App, + tui: &mut crate::tui::Tui, + server: &mut AppServerSession, + events: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Result<()> { + drain_managed_worktree_start(app, server).await; + assert!( + app.pending_managed_worktree_creation, + "checkout task started" + ); + loop { + let event = tokio::time::timeout(Duration::from_secs(15), events.recv()) + .await? + .ok_or_else(|| color_eyre::eyre::eyre!("worktree creation event channel closed"))?; + if matches!(event, AppEvent::ManagedWorktreeCreated(_)) { + app.handle_event(tui, server, event).await?; + if let Some(created) = app.pending_managed_worktree_created.take() { + app.finish_managed_worktree(*created).await; + } + if let Some(transition) = app.pending_managed_worktree_transition.take() { + assert!(app.pending_managed_worktree_attach.is_none()); + if let Err(error) = app + .switch_to_managed_worktree(tui, server, *transition) + .await + { + app.chat_widget.add_error_message(error.to_string()); + } + if let Some(attach) = app.pending_managed_worktree_attach.take() { + app.attach_working_directory(tui, server, *attach).await; + } + } + return Ok(()); + } + } +} + +#[tokio::test] +async fn same_thread_retry_keeps_subscription_and_restores_draft() -> Result<()> { + let (mut app, codex_home) = make_history_test_app().await?; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2026-01-01T00-00-00", + "2026-01-01T00:00:00Z", + "Saved user message", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create rollout"), + )?; + let path = Some(rollout_path( + codex_home.path(), + "2026-01-01T00-00-00", + &thread_id.to_string(), + )); + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + app.ensure_thread_channel(thread_id).mark_external_writer(); + app.chat_widget.insert_str("Retained draft"); + app.chat_widget.show_external_writer_thread(); + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + app.config.cwd.as_path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf()); + requests.lock().expect("request recorder lock").clear(); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.resume_target_session( + &mut tui, + &mut app_server, + crate::resume_picker::SessionTarget { + path, + thread_id, + cwd: None, + history_mode: None, + }, + ) + .await?; + assert_eq!(recorded_params(&requests, "thread/resume").len(), 1); + assert!(recorded_params(&requests, "thread/unsubscribe").is_empty()); + assert!(!app.chat_widget.is_external_writer_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + proxy.abort(); + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum HistoryCapabilities { + Current, + LegacyOnly, + LegacyOnlyUnsupportedVariant, + LegacyDynamicToolsAndHistory, + ForkHydrationFails, + ReadAfterResumeFails, + ThreadListFails, + ThreadStartFails, + ConfigReadUnsupported(i64), + ConfigReadFails, + ConfigReadUnknownVoice, + VoiceCatalogCustom, + VoiceCatalogUnavailable, +} + +/// Returns and resets `(thread/loaded/list, thread/read)` request counts. +fn take_backfill_counts(requests: &RecordedRequests) -> (usize, usize) { + let requests = std::mem::take(&mut *requests.lock().expect("request recorder lock")); + ( + requests + .iter() + .filter(|request| request.method == "thread/loaded/list") + .count(), + requests + .iter() + .filter(|request| request.method == "thread/read") + .count(), + ) +} + +/// Starts an embedded app server behind a loopback WebSocket proxy that records JSON-RPC methods. +pub(super) async fn start_recording_app_server( + config: &Config, + blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>, + failed_thread_name: Option<&'static str>, +) -> Result { + start_recording_app_server_with_history( + config, + HistoryCapabilities::Current, + blocked_thread_list, + failed_thread_name, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await +} + +pub(super) async fn start_recording_remote_app_server( + config: &Config, +) -> Result { + start_recording_app_server_with_history( + config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + LoaderOverrides::default(), + ) + .await +} + +/// Proxies a real app server while optionally rejecting modern pagination like an older server. +pub(super) async fn start_recording_app_server_with_history( + config: &Config, + history_capabilities: HistoryCapabilities, + blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>, + failed_thread_name: Option<&'static str>, + thread_params_mode: crate::app_server_session::ThreadParamsMode, + loader_overrides: LoaderOverrides, +) -> Result { + start_recording_app_server_with_realtime_speech( + config, + history_capabilities, + blocked_thread_list, + failed_thread_name, + thread_params_mode, + RealtimeRequestBehavior::Forward, + loader_overrides, + ) + .await +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum RealtimeRequestBehavior { + Forward, + AcceptStart, + AcceptSpeech, + AcceptSpeechAndStallStop, +} + +pub(super) async fn start_recording_realtime_speech_app_server( + config: &Config, + realtime_behavior: RealtimeRequestBehavior, +) -> Result { + start_recording_app_server_with_realtime_speech( + config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + realtime_behavior, + LoaderOverrides::default(), + ) + .await +} + +pub(super) async fn start_recording_app_server_with_realtime_speech( + config: &Config, + history_capabilities: HistoryCapabilities, + mut blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>, + failed_thread_name: Option<&'static str>, + thread_params_mode: crate::app_server_session::ThreadParamsMode, + realtime_behavior: RealtimeRequestBehavior, + loader_overrides: LoaderOverrides, +) -> Result { + let state_db = + crate::init_state_db_for_app_server_target(config, &crate::AppServerTarget::Embedded) + .await?; + let embedded = crate::start_embedded_app_server( + codex_arg0::Arg0DispatchPaths::default(), + config.clone(), + Vec::new(), + loader_overrides, + /*strict_config*/ false, + codex_config::CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + state_db, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ) + .await?; + let codex_home = config.codex_home.display().to_string(); + let requests = Arc::new(Mutex::new(Vec::new())); + let request_sink = Arc::clone(&requests); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let proxy = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + let mut websocket = accept_async(stream).await?; + let mut inventories = usize::from(failed_thread_name == Some("background")); + let mut reject_detach = false; + let mut reject_thread_list = history_capabilities == HistoryCapabilities::ThreadListFails; + while let Some(frame) = websocket.next().await { + let Message::Text(text) = frame? else { + continue; + }; + let message = serde_json::from_str::(&text)?; + match message { + JSONRPCMessage::Request(request) if request.method == "initialize" => { + websocket + .send(Message::Text( + serde_json::to_string(&JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::json!({ + "userAgent": "codex-tui-test", + "codexHome": codex_home, + }), + }))? + .into(), + )) + .await?; + } + JSONRPCMessage::Request(request) => { + request_sink + .lock() + .expect("request recorder lock") + .push(request.clone()); + if realtime_behavior == RealtimeRequestBehavior::AcceptSpeechAndStallStop + && request.method == "thread/realtime/stop" + { + continue; + } + let request_id = request.id.clone(); + let params = request.params.as_ref(); + let requires_pagination = match request.method.as_str() { + "thread/start" => params + .and_then(|params| params.get("historyMode")) + .is_some_and(|mode| !mode.is_null()), + "thread/resume" | "thread/fork" => params + .and_then(|params| params.get("excludeTurns")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + "thread/turns/list" | "thread/items/list" => true, + _ => false, + }; + let reject_fork_hydration = history_capabilities + == HistoryCapabilities::ForkHydrationFails + && request.method == "thread/items/list" + && request_sink + .lock() + .expect("request recorder lock") + .iter() + .any(|recorded| recorded.method == "thread/fork"); + let reject_dynamic_tools = history_capabilities + == HistoryCapabilities::LegacyDynamicToolsAndHistory + && request.method == "thread/start" + && params + .and_then(|params| params.get("dynamicTools")) + .and_then(serde_json::Value::as_array) + .is_some_and(|tools| { + tools.iter().any(|tool| tool["type"] == "namespace") + }); + let fake_realtime_response = (realtime_behavior + == RealtimeRequestBehavior::AcceptStart + && request.method == "thread/realtime/start") + || (matches!( + realtime_behavior, + RealtimeRequestBehavior::AcceptSpeech + | RealtimeRequestBehavior::AcceptSpeechAndStallStop + ) && request.method == "thread/realtime/appendSpeech"); + let response = if fake_realtime_response { + JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result: serde_json::json!({}), + }) + } else if history_capabilities == HistoryCapabilities::ReadAfterResumeFails + && request.method == "thread/read" + && request_sink + .lock() + .expect("request recorder lock") + .iter() + .any(|recorded| recorded.method == "thread/resume") + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32603, + data: None, + message: "saved history unavailable".to_string(), + }, + }) + } else if let HistoryCapabilities::ConfigReadUnsupported(code) = + history_capabilities + && request.method == "config/read" + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code, + data: None, + message: "unknown variant `config/read`".to_string(), + }, + }) + } else if history_capabilities == HistoryCapabilities::ConfigReadFails + && request.method == "config/read" + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32603, + data: None, + message: "config temporarily unavailable".to_string(), + }, + }) + } else if history_capabilities == HistoryCapabilities::VoiceCatalogUnavailable + && request.method == "thread/realtime/listVoices" + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32601, + data: None, + message: "method not found".to_string(), + }, + }) + } else if history_capabilities == HistoryCapabilities::ThreadStartFails + && request.method == "thread/start" + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32603, + data: None, + message: "replacement unavailable".to_string(), + }, + }) + } else if request.method == "thread/list" + && std::mem::take(&mut reject_thread_list) + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32603, + data: None, + message: "thread listing unavailable".to_string(), + }, + }) + } else if history_capabilities == HistoryCapabilities::LegacyOnly + && request.method == "thread/list" + && params.is_some_and(|params| params["sortKey"] == "recency_at") + { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32602, + data: None, + message: "unknown variant `recency_at`".to_string(), + }, + }) + } else if reject_dynamic_tools { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32602, + data: None, + message: "missing field `inputSchema`".to_string(), + }, + }) + } else if matches!( + history_capabilities, + HistoryCapabilities::LegacyOnly + | HistoryCapabilities::LegacyOnlyUnsupportedVariant + | HistoryCapabilities::LegacyDynamicToolsAndHistory + ) && requires_pagination + { + let (code, message) = if history_capabilities + == HistoryCapabilities::LegacyOnlyUnsupportedVariant + && request.method == "thread/start" + { + (-32602, "unknown variant \"paginated\", expected \"legacy\"") + } else { + (-32601, "method not found") + }; + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code, + data: None, + message: message.to_string(), + }, + }) + } else if reject_fork_hydration { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32603, + data: None, + message: "fork history hydration failed".to_string(), + }, + }) + } else { + let background = request.method == "thread/backgroundTerminals/list" && { + inventories += usize::from(inventories > 0); + matches!(inventories, 2 | 4) + }; + let detach = request.method == "thread/unsubscribe"; + let request = serde_json::from_value::( + serde_json::to_value(request)?, + )?; + if let ClientRequest::ThreadList { params, .. } = &request + && let Some((root, started, release)) = blocked_thread_list.take() + { + assert_eq!(params.ancestor_thread_id, Some(root.to_string())); + assert_eq!(params.sort_direction, Some(SortDirection::Desc)); + let _ = started.send(()); + let _ = release.await; + } + let force_failure = matches!( + &request, + ClientRequest::ThreadSetName { params, .. } + if failed_thread_name == Some(params.name.as_str()) + ) || matches!( + &request, + ClientRequest::ThreadFork { params, .. } + if params.cwd.as_deref().is_some_and(|cwd| cwd.ends_with("failure")) + && { reject_detach = true; true } + ) || (detach && std::mem::take(&mut reject_detach)); + if force_failure { + JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error: JSONRPCErrorError { + code: -32603, + message: "forced thread/name/set failure".to_string(), + data: None, + }, + }) + } else { + let unknown_voice = history_capabilities + == HistoryCapabilities::ConfigReadUnknownVoice + && matches!(&request, ClientRequest::ConfigRead { .. }); + let custom_voice_catalog = history_capabilities + == HistoryCapabilities::VoiceCatalogCustom + && matches!( + &request, + ClientRequest::ThreadRealtimeListVoices { .. } + ); + let mut result = embedded.request(request).await?; + if unknown_voice && let Ok(value) = &mut result { + value["config"]["realtime"]["voice"] = + serde_json::json!("future_voice"); + } + if custom_voice_catalog && let Ok(value) = &mut result { + value["voices"]["v1"] = + serde_json::json!(["maple", "cove", "juniper"]); + value["voices"]["defaultV1"] = serde_json::json!("maple"); + } + if background { + let terminal = r#"{"data":[{"itemId":"x","processId":"x","command":"x","cwd":"/"}],"nextCursor":null}"#; + result = Ok(serde_json::from_str(terminal)?); + } + match result { + Ok(result) => JSONRPCMessage::Response(JSONRPCResponse { + id: request_id, + result, + }), + Err(error) => JSONRPCMessage::Error(JSONRPCError { + id: request_id, + error, + }), + } + } + }; + websocket + .send(Message::Text(serde_json::to_string(&response)?.into())) + .await?; + } + JSONRPCMessage::Notification(notification) + if notification.method == "initialized" => {} + JSONRPCMessage::Notification(notification) => { + embedded + .notify(serde_json::from_value::( + serde_json::to_value(notification)?, + )?) + .await?; + } + JSONRPCMessage::Response(response) => { + request_sink + .lock() + .expect("request recorder lock") + .push(JSONRPCRequest { + id: response.id, + method: "server/request/response".to_string(), + params: Some(response.result), + trace: None, + }); + } + JSONRPCMessage::Error(_) => {} + } + } + embedded.shutdown().await?; + Ok(()) + }); + let app_server = crate::connect_remote_app_server(crate::RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: None, + }) + .await?; + + Ok(( + AppServerSession::new(app_server, thread_params_mode) + .with_local_codex_home(&config.codex_home), + requests, + proxy, + )) +} + +fn create_history_rollout( + config: &Config, + history_mode: ThreadHistoryMode, + preview: &str, +) -> Result { + let create_rollout = match history_mode { + ThreadHistoryMode::Legacy => create_fake_rollout, + ThreadHistoryMode::Paginated => create_fake_paginated_rollout, + }; + let thread_id = create_rollout( + config.codex_home.as_path(), + "2026-01-02T00-00-00", + "2026-01-02T00:00:00Z", + preview, + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .map_err(|err| color_eyre::eyre::eyre!("failed to create history rollout: {err}"))?; + Ok(ThreadId::from_string(&thread_id)?) +} + +pub(super) fn recorded_params(requests: &RecordedRequests, method: &str) -> Vec { + requests + .lock() + .expect("request recorder lock") + .iter() + .filter(|request| request.method == method) + .map(|request| request.params.clone().unwrap_or(serde_json::Value::Null)) + .collect() +} + +async fn make_history_test_app() -> Result<(App, tempfile::TempDir)> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + Ok((app, codex_home)) +} + +#[tokio::test] +async fn delete_current_thread_navigates_only_after_success() -> Result<()> { + let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; + for target in [ + AppServerTarget::Embedded, + AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }, + AppServerTarget::Remote { endpoint }, + ] { + let (mut app, _codex_home) = make_history_test_app().await?; + let thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "delete me")?; + let (mut server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + app.app_server_target = target; + let mut tui = crate::tui::test_support::make_test_tui()?; + let missing_id = ThreadId::new(); + app.active_thread_id = Some(missing_id); + app.chat_widget.insert_str("Retained on failure"); + assert_matches!( + app.delete_current_thread(&mut tui, &mut server).await?, + AppRunControl::Continue + ); + assert_eq!(app.active_thread_id, Some(missing_id)); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained on failure" + ); + assert!(!app.chat_widget.has_active_view()); + + let resumed = server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + app.active_thread_id = Some(thread_id); + app.enqueue_primary_thread_session(resumed.session.clone(), resumed.turns) + .await?; + app.chat_widget.handle_thread_session(resumed.session); + let mut side_config = app.config.clone(); + side_config.ephemeral = true; + let side = server + .fork_side_thread(&app.local_settings, side_config.clone(), thread_id) + .await?; + let side_id = side.session.thread_id; + app.side_threads + .insert(side_id, SideThreadState::new(thread_id)); + if !matches!(app.app_server_target, AppServerTarget::Embedded) { + // The recording proxy rejects the next unsubscribe after a failed fork. + side_config.cwd = side_config.cwd.join("failure"); + assert!( + server + .fork_side_thread(&app.local_settings, side_config, thread_id) + .await + .is_err() + ); + assert_matches!( + app.delete_current_thread(&mut tui, &mut server).await?, + AppRunControl::Continue + ); + assert_eq!(app.active_thread_id, Some(thread_id)); + assert!(app.side_threads.contains_key(&side_id)); + assert!(!app.chat_widget.has_active_view()); + assert_eq!(recorded_params(&requests, "thread/delete").len(), 1); + } + let (tx, mut events) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let control = + Box::pin(app.handle_event(&mut tui, &mut server, AppEvent::DeleteCurrentThread)) + .await?; + assert!( + server + .thread_read(thread_id, /*include_turns*/ false) + .await + .is_err() + ); + if matches!(app.app_server_target, AppServerTarget::Embedded) { + assert_matches!(control, AppRunControl::Exit(ExitReason::ThreadRemoved)); + assert!(recorded_params(&requests, "thread/unsubscribe").is_empty()); + } else { + assert_matches!(control, AppRunControl::Continue); + assert!(app.side_threads.is_empty()); + assert_eq!( + recorded_params(&requests, "thread/unsubscribe"), + vec![serde_json::json!({"threadId": side_id.to_string()}); 2] + ); + loop { + let event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("command center refresh"); + let refreshed = matches!(&event, AppEvent::AgentsOverviewThreadsLoaded { .. }); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + if refreshed { + break; + } + } + assert_eq!( + ( + app.active_thread_id, + app.primary_thread_id, + app.chat_widget.thread_id() + ), + (None, None, None) + ); + assert!(app.chat_widget.composer_is_empty()); + assert!(app.chat_widget.has_active_view()); + } + assert_eq!( + recorded_params(&requests, "thread/delete"), + vec![ + serde_json::json!({"threadId": missing_id.to_string()}), + serde_json::json!({"threadId": thread_id.to_string()}), + ] + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +fn spawn_approved_task_tool_call( + app: &App, + app_server: &AppServerSession, + request_id: AppServerRequestId, + params: codex_app_server_protocol::DynamicToolCallParams, +) { + let request_handle = app_server.request_handle(); + let app_event_tx = app.app_event_tx.clone(); + let status_updates = app.dynamic_tool_status_updates.subscribe(); + let mut thread_start_params = crate::app_server_session::thread_start_params_from_config( + &app.config, + app_server.thread_params_mode(), + app_server.remote_cwd_override(), + /*session_start_source*/ None, + ); + app_server + .thread_tool_transport() + .configure(&mut thread_start_params); + tokio::spawn(async move { + let response = crate::dynamic_tools::execute( + request_handle, + params, + thread_start_params, + status_updates, + Some(&app_event_tx), + ) + .await; + app_event_tx.send(AppEvent::DynamicToolCallCompleted { + request_id, + response, + }); + }); +} + +#[tokio::test] +async fn external_transport_registers_dynamic_tools_and_finds_task_mentions() -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let (mut app_server, requests, proxy) = Box::pin(start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + )) + .await?; + + let started = app_server.start_thread(&app.config).await?; + assert!(started.task_tools_available); + assert!(app_server.task_tools_available(started.session.thread_id)); + let startup = crate::app_server_session::start_thread_with_request_handle( + app_server.request_handle(), + &app.local_settings, + app.config.clone(), + crate::app_server_session::ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + app_server.thread_tool_transport(), + ) + .await?; + assert!(startup.task_tools_available); + + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 2); + for params in starts { + assert_eq!(params["dynamicTools"][0]["type"], "namespace"); + assert_eq!(params["dynamicTools"][0]["name"], "codex_tui"); + assert_eq!( + params["dynamicTools"][0]["tools"].as_array().map(Vec::len), + Some(6) + ); + assert!( + params["dynamicTools"][0]["tools"] + .as_array() + .is_some_and(|tools| tools.iter().all(|tool| { + tool["deferLoading"] == true + && !crate::dynamic_tools::DELEGATION_TOOLS + .contains(&tool["name"].as_str().unwrap_or_default()) + })) + ); + } + let target_id = started.session.thread_id; + app_server + .thread_inject_items(target_id, vec![App::side_boundary_prompt_item()]) + .await?; + crate::init_state_db_for_app_server_target(&app.config, &crate::AppServerTarget::Embedded) + .await? + .expect("state database") + .set_thread_preview_if_empty(target_id, "Review database migration") + .await + .expect("seed searchable thread preview"); + app_server.shutdown().await?; + proxy.await??; + let (mut restarted_app_server, _restarted_requests, restarted_proxy) = + Box::pin(start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + )) + .await?; + let resumed = Box::pin(restarted_app_server.resume_thread( + &app.local_settings, + app.config.clone(), + target_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + )) + .await?; + assert!(resumed.task_tools_available); + assert!(restarted_app_server.task_tools_available(target_id)); + let forked = Box::pin(restarted_app_server.fork_thread( + &app.local_settings, + app.config.clone(), + target_id, + )) + .await?; + assert!(forked.task_tools_available); + assert!(restarted_app_server.task_tools_available(forked.session.thread_id)); + restarted_app_server + .thread_set_name(target_id, "Bluebird".to_string()) + .await?; + let (sender, mut events) = tokio::sync::mpsc::unbounded_channel(); + + crate::task_mentions::spawn_search( + restarted_app_server.request_handle(), + "bbd".to_string(), + startup.session.thread_id, + app.config.cwd.to_path_buf(), + restarted_app_server.task_search_generation(), + crate::app_event_sender::AppEventSender::new(sender), + ); + + let event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("expected task search results"); + let AppEvent::TaskSearchResult { matches, .. } = event else { + panic!("expected task search results"); + }; + assert!( + matches + .iter() + .any(|task| task.thread_id == target_id.to_string() && task.title == "Bluebird"), + "expected created task in {matches:?}" + ); + + restarted_app_server.shutdown().await?; + restarted_proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn archive_current_thread_reports_success_only_after_archiving() -> Result<()> { + let (mut app, _codex_home) = make_history_test_app().await?; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + &app.config.codex_home, + "2026-08-25T01-00-00", + "2026-08-25T01:00:00Z", + "archive me", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("create rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + + let mut tui = crate::tui::test_support::make_test_tui()?; + app.active_thread_id = Some(ThreadId::new()); + assert_matches!( + app.archive_current_thread(&mut tui, &mut app_server) + .await?, + AppRunControl::Continue + ); + + app.active_thread_id = Some(thread_id); + assert_matches!( + app.archive_current_thread(&mut tui, &mut app_server).await?, + AppRunControl::Exit(ExitReason::Archived(archived_id)) if archived_id == thread_id + ); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn archive_current_thread_returns_shared_servers_to_agents() -> Result<()> { + let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; + for target in [ + AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }, + AppServerTarget::Remote { endpoint }, + ] { + let (mut app, _codex_home) = make_history_test_app().await?; + let thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "archive me")?; + let (mut server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let resumed = server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let mut side_config = app.config.clone(); + side_config.ephemeral = true; + let side = server + .fork_side_thread(&app.local_settings, side_config, thread_id) + .await?; + let side_id = side.session.thread_id; + app.side_threads + .insert(side_id, SideThreadState::new(thread_id)); + app.app_server_target = target; + app.enqueue_primary_thread_session(resumed.session.clone(), resumed.turns) + .await?; + app.chat_widget.handle_thread_session(resumed.session); + app.chat_widget.insert_str("Unsent archived draft"); + let mut tui = crate::tui::test_support::make_test_tui()?; + let (tx, mut events) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + + assert_matches!( + Box::pin(app.handle_event(&mut tui, &mut server, AppEvent::ArchiveCurrentThread)) + .await?, + AppRunControl::Continue + ); + loop { + let event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("expected command center refresh"); + let refreshed = matches!(&event, AppEvent::AgentsOverviewThreadsLoaded { .. }); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + if refreshed { + break; + } + } + assert!(app.agents_overview.initialized); + assert_eq!( + ( + app.active_thread_id, + app.primary_thread_id, + app.chat_widget.thread_id() + ), + (None, None, None) + ); + assert!(!app.agents_overview.threads.contains_key(&thread_id)); + assert!(app.thread_event_channels.is_empty()); + assert!(app.side_threads.is_empty()); + assert_eq!( + recorded_params(&requests, "thread/unsubscribe"), + vec![serde_json::json!({"threadId": side_id.to_string()})] + ); + assert!(app.chat_widget.composer_is_empty()); + assert_eq!( + recorded_params(&requests, "thread/archive"), + vec![serde_json::json!({"threadId": thread_id.to_string()})] + ); + assert!( + app.chat_widget + .selected_index_for_present_view( + crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID + ) + .is_some() + ); + assert_snapshot!( + "agents_command_center_after_archive", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() -> Result<()> { + let (mut app, events, _ops) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.config + .web_search_mode + .set(codex_protocol::config_types::WebSearchMode::Live)?; + std::fs::write( + codex_home.path().join("config.toml"), + "web_search = \"disabled\"\n", + )?; + let (mut app_server, mut requests, mut proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + app_server + .start_dynamic_tool_mcp( + app.config.clone(), + app.app_event_tx.clone(), + app.dynamic_tool_status_updates.clone(), + ) + .await?; + + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + assert!(started.task_tools_available); + assert!(app_server.task_tools_available(thread_id)); + let startup = crate::app_server_session::start_thread_with_request_handle( + app_server.request_handle(), + &app.local_settings, + app.config.clone(), + crate::app_server_session::ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + app_server.thread_tool_transport(), + ) + .await?; + assert!(startup.task_tools_available); + + let inventory: codex_app_server_protocol::ListMcpServerStatusResponse = app_server + .request_handle() + .request_typed(ClientRequest::McpServerStatusList { + request_id: AppServerRequestId::String("tui-tool-inventory".to_string()), + params: codex_app_server_protocol::ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(codex_app_server_protocol::McpServerStatusDetail::ToolsAndAuthOnly), + thread_id: Some(thread_id.to_string()), + }, + }) + .await?; + let tools = &inventory + .data + .iter() + .find(|server| server.name == "codex_tui") + .expect("local daemon must connect to the TUI MCP server") + .tools; + assert_eq!(tools.len(), 9); + for tool in crate::dynamic_tools::DELEGATION_TOOLS { + assert!(tools.contains_key(tool)); + } + assert!( + !tools["create_thread"] + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .is_some_and(|properties| properties.contains_key("permissions")) + ); + + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 2); + for params in &starts { + assert_eq!(params["dynamicTools"], serde_json::Value::Null); + assert_eq!(params["config"]["web_search"], "live"); + let server = ¶ms["config"]["mcp_servers.codex_tui"]; + assert!( + server["url"] + .as_str() + .is_some_and(|url| url.starts_with("http://127.0.0.1:")) + ); + assert!( + server["http_headers"]["Authorization"] + .as_str() + .is_some_and(|header| header.starts_with("Bearer ")) + ); + assert_eq!(server["default_tools_approval_mode"], "approve"); + for tool in crate::dynamic_tools::DELEGATION_TOOLS { + assert_eq!(server["tools"][tool]["approval_mode"], "prompt"); + } + } + + let mcp_url = starts[0]["config"]["mcp_servers.codex_tui"]["url"] + .as_str() + .expect("MCP server URL"); + let unauthorized = codex_http_client::HttpClientBuilder::new() + .build_direct()? + .post(mcp_url) + .send() + .await?; + assert_eq!(unauthorized.status().as_u16(), 401); + + app.config + .web_search_mode + .set(codex_protocol::config_types::WebSearchMode::Disabled)?; + let delegation_source = create_history_rollout( + &app.config, + ThreadHistoryMode::Legacy, + "Approved task source", + )?; + app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + delegation_source, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let resumed = recorded_params(&requests, "thread/resume") + .pop() + .expect("resumed task request"); + assert_eq!( + resumed["config"]["mcp_servers.codex_tui"], + starts[0]["config"]["mcp_servers.codex_tui"] + ); + app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + delegation_source, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await?; + let reattached = recorded_params(&requests, "thread/resume") + .pop() + .expect("reattached task request"); + assert_eq!( + reattached["config"]["mcp_servers.codex_tui"], + starts[0]["config"]["mcp_servers.codex_tui"] + ); + app_server + .fork_thread(&app.local_settings, app.config.clone(), delegation_source) + .await?; + let forked = recorded_params(&requests, "thread/fork") + .pop() + .expect("forked task request"); + assert_eq!( + forked["config"]["mcp_servers.codex_tui"], + starts[0]["config"]["mcp_servers.codex_tui"] + ); + let authorization = + starts[0]["config"]["mcp_servers.codex_tui"]["http_headers"]["Authorization"] + .as_str() + .expect("MCP bearer token"); + let client = codex_http_client::HttpClientBuilder::new().build_direct()?; + let call_tool = |id: u32, tool: &'static str, arguments: serde_json::Value| { + client + .post(mcp_url) + .header("Authorization", authorization) + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("MCP-Method", "tools/call") + .header("MCP-Name", tool) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { + "name": tool, + "arguments": arguments, + "_meta": { + "threadId": delegation_source, + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })) + }; + let transport = app_server.thread_tool_transport(); + let crate::dynamic_tools_mcp::ThreadToolTransport::Mcp(tool_server) = &transport else { + panic!("expected the daemon task-tool bridge"); + }; + tool_server.suspend(); + let paused = call_tool(0, "list_threads", serde_json::json!({})) + .send() + .await? + .text() + .await?; + assert!( + paused.contains("TUI is reconnecting; tool was not sent"), + "{paused}" + ); + let (replacement, replacement_requests, replacement_proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let (new_tx, new_rx) = mpsc::unbounded_channel(); + let new_sender = AppEventSender::new(new_tx); + drop(events); + let mut events = new_rx; + assert!(app.app_event_tx.app_event_tx.is_closed()); + tool_server.reconnect(replacement.request_handle(), new_sender); + let previous = std::mem::replace( + &mut app_server, + replacement.with_thread_tool_transport(transport), + ); + previous.shutdown().await?; + proxy.await??; + requests = replacement_requests; + proxy = replacement_proxy; + // The same MCP URL and credentials now use the new connection and event receiver. + let response = call_tool(1, "list_threads", serde_json::json!({})) + .send() + .await?; + let status = response.status(); + let response = response.text().await?; + assert!(status.is_success(), "{status}: {response}"); + assert!(response.contains("threads"), "{response}"); + + let mut creation = tokio::spawn( + call_tool( + 2, + "create_thread", + serde_json::json!({"prompt": "Start an approved task"}), + ) + .send(), + ); + let registration = tokio::select! { + event = events.recv() => event.expect("approved MCP task must register before starting"), + response = &mut creation => { + let response = response??; + panic!("MCP task creation completed without registration: {}", response.text().await?); + } + _ = tokio::time::sleep(std::time::Duration::from_secs(/*secs*/ 5)) => { + panic!("timed out waiting for MCP task registration"); + } + }; + let AppEvent::DynamicToolThreadStarted { + thread_id: child_thread_id, + task_tools_available, + registered, + } = registration + else { + panic!("expected the MCP-created task to register") + }; + assert!(task_tools_available); + assert!(registered.send(()).is_ok()); + let created = creation.await??; + assert!(created.status().is_success()); + assert!(created.text().await?.contains(&child_thread_id.to_string())); + let child = recorded_params(&requests, "thread/start") + .pop() + .expect("MCP child thread/start request"); + assert_eq!(child["dynamicTools"], serde_json::Value::Null); + assert!(child["config"]["web_search"].is_null()); + assert_eq!( + child["config"]["mcp_servers.codex_tui"], + starts[0]["config"]["mcp_servers.codex_tui"] + ); + let forked = call_tool( + 3, + "fork_thread", + serde_json::json!({"threadId": delegation_source}), + ) + .send() + .await?; + assert!(forked.status().is_success()); + let forked = recorded_params(&requests, "thread/fork") + .pop() + .expect("MCP-created fork request"); + assert_eq!( + forked["config"]["mcp_servers.codex_tui"], + starts[0]["config"]["mcp_servers.codex_tui"] + ); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn local_mcp_respects_configured_servers_and_managed_requirements() -> Result<()> { + for scenario in ["conflicting", "blocked", "mismatched", "allowed"] { + let (mut app, _codex_home) = make_history_test_app().await?; + if scenario == "conflicting" { + let raw = serde_json::from_value::( + serde_json::json!({"url": "http://127.0.0.1:1/mcp", "enabled": false}), + )?; + let mut servers = app.config.mcp_servers.get().clone(); + servers.insert( + crate::dynamic_tools::NAMESPACE.to_string(), + codex_config::McpServerConfig::try_from(raw) + .map_err(color_eyre::eyre::Report::msg)?, + ); + app.config.mcp_servers.set(servers)?; + } else { + let mut allowed_servers = std::collections::BTreeMap::new(); + if matches!(scenario, "mismatched" | "allowed") { + let requirement = if scenario == "allowed" { + codex_config::McpServerRequirement::Url( + codex_protocol::mcp_policy::McpServerValueMatcher::Prefix { + value: "http://127.0.0.1:".to_string(), + }, + ) + } else { + codex_config::McpServerRequirement::Identity { + identity: codex_config::McpServerIdentity::Url { + url: "http://127.0.0.1:1/mcp".to_string(), + }, + } + }; + allowed_servers.insert(crate::dynamic_tools::NAMESPACE.to_string(), requirement); + } + let requirements = codex_config::ConfigRequirements { + mcp_servers: Some(codex_config::Sourced::new( + allowed_servers, + codex_config::RequirementSource::Unknown, + )), + ..Default::default() + }; + app.config.config_layer_stack = codex_config::ConfigLayerStack::new( + Vec::new(), + requirements, + codex_config::ConfigRequirementsToml::default(), + )?; + } + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let result = app_server + .start_dynamic_tool_mcp( + app.config.clone(), + app.app_event_tx.clone(), + app.dynamic_tool_status_updates.clone(), + ) + .await; + if scenario == "allowed" { + result?; + } else { + let error = result.expect_err("unavailable internal MCP must fail closed"); + assert_eq!( + error.kind(), + if scenario == "conflicting" { + std::io::ErrorKind::AlreadyExists + } else { + std::io::ErrorKind::PermissionDenied + } + ); + } + app_server.start_thread(&app.config).await?; + let start = recorded_params(&requests, "thread/start") + .pop() + .expect("fallback task start"); + if scenario == "allowed" { + assert!(start["dynamicTools"].is_null()); + assert!(start["config"]["mcp_servers.codex_tui"].is_object()); + } else { + assert_eq!( + start["dynamicTools"][0]["tools"].as_array().map(Vec::len), + Some(6) + ); + assert!(start["config"]["mcp_servers.codex_tui"].is_null()); + } + app_server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn older_external_server_starts_without_unsupported_dynamic_tools_or_history() -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::LegacyDynamicToolsAndHistory, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + + let started = app_server.start_thread(&app.config).await?; + assert!(!started.task_tools_available); + assert!(!app_server.task_tools_available(started.session.thread_id)); + let startup = crate::app_server_session::start_thread_with_request_handle( + app_server.request_handle(), + &app.local_settings, + app.config.clone(), + crate::app_server_session::ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + app_server.thread_tool_transport(), + ) + .await?; + assert!(!startup.task_tools_available); + + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 6); + for attempts in starts.chunks_exact(3) { + assert_eq!(attempts[0]["dynamicTools"][0]["type"], "namespace"); + assert_eq!(attempts[0]["historyMode"], "paginated"); + assert_eq!(attempts[1]["dynamicTools"], serde_json::Value::Null); + assert_eq!(attempts[1]["historyMode"], "paginated"); + assert_eq!(attempts[2]["dynamicTools"], serde_json::Value::Null); + assert_eq!(attempts[2]["historyMode"], serde_json::Value::Null); + } + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn embedded_server_rejects_unowned_dynamic_tool_calls() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new( + ServerRequest::DynamicToolCall { + request_id: AppServerRequestId::Integer(100), + params: codex_app_server_protocol::DynamicToolCallParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + call_id: "call-1".to_string(), + namespace: Some("codex_app".to_string()), + tool: "list_threads".to_string(), + arguments: serde_json::json!({}), + }, + }, + )), + ) + .await; + let AppEvent::DynamicToolCallCompleted { response, .. } = events + .try_recv() + .expect("embedded dynamic calls must receive a response") + else { + panic!("expected a dynamic tool failure response") + }; + assert!(!response.success); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespace() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.config + .permissions + .set_permission_profile(PermissionProfile::workspace_write_with( + &[app.config.cwd.clone()], + codex_protocol::permissions::NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ))?; + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ Some("Unavailable name"), + ) + .await?; + let thread_id = app_server + .start_thread(&app.config) + .await? + .session + .thread_id + .to_string(); + + for namespace in [Some("codex_app"), None] { + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new( + ServerRequest::DynamicToolCall { + request_id: AppServerRequestId::Integer(100), + params: codex_app_server_protocol::DynamicToolCallParams { + thread_id: thread_id.clone(), + turn_id: "turn-1".to_string(), + call_id: "call-1".to_string(), + namespace: namespace.map(str::to_string), + tool: "list_threads".to_string(), + arguments: serde_json::json!({}), + }, + }, + )), + ) + .await; + assert!(events.try_recv().is_err()); + } + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new( + ServerRequest::DynamicToolCall { + request_id: AppServerRequestId::Integer(101), + params: codex_app_server_protocol::DynamicToolCallParams { + thread_id: thread_id.clone(), + turn_id: "turn-1".to_string(), + call_id: "call-2".to_string(), + namespace: Some("codex_tui".to_string()), + tool: "list_threads".to_string(), + arguments: serde_json::json!({}), + }, + }, + )), + ) + .await; + + let event = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("dynamic tool completion event"); + let AppEvent::DynamicToolCallCompleted { + request_id, + response, + } = event + else { + panic!("expected a dynamic tool completion event") + }; + assert_eq!(request_id, AppServerRequestId::Integer(101)); + assert!(response.success, "{response:?}"); + let list_requests = recorded_params(&requests, "thread/list"); + assert_eq!(list_requests.len(), 1); + assert_eq!(list_requests[0]["useStateDbOnly"], true); + assert_eq!(list_requests[0]["sourceKinds"], serde_json::Value::Null); + + let mut tui = crate::tui::test_support::make_test_tui()?; + // Box each dispatcher await so this test does not retain its large future inline. + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::DynamicToolCallCompleted { + request_id, + response, + }, + )) + .await?; + let completed = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), async { + loop { + if let Some(response) = recorded_params(&requests, "server/request/response").pop() { + break response; + } + tokio::task::yield_now().await; + } + }) + .await?; + assert_eq!(completed["success"], true); + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new( + ServerRequest::DynamicToolCall { + request_id: AppServerRequestId::Integer(102), + params: codex_app_server_protocol::DynamicToolCallParams { + thread_id: thread_id.clone(), + turn_id: "turn-1".to_string(), + call_id: "call-3".to_string(), + namespace: Some("codex_tui".to_string()), + tool: "set_thread_title".to_string(), + arguments: serde_json::json!({"threadId": thread_id, "title": "Renamed"}), + }, + }, + )), + ) + .await; + let AppEvent::DynamicToolCallCompleted { response, .. } = + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("dynamic mutation completion event") + else { + panic!("expected a dynamic mutation completion event") + }; + assert!(response.success, "{response:?}"); + assert_eq!( + recorded_params(&requests, "thread/name/set")[0]["name"], + "Renamed" + ); + + for (index, tool) in crate::dynamic_tools::DELEGATION_TOOLS + .into_iter() + .enumerate() + { + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new( + ServerRequest::DynamicToolCall { + request_id: AppServerRequestId::String(format!("rejected-{index}")), + params: codex_app_server_protocol::DynamicToolCallParams { + thread_id: thread_id.clone(), + turn_id: "turn-1".to_string(), + call_id: format!("rejected-{index}"), + namespace: Some("codex_tui".to_string()), + tool: tool.to_string(), + arguments: serde_json::json!({}), + }, + }, + )), + ) + .await; + let AppEvent::DynamicToolCallCompleted { response, .. } = events + .try_recv() + .expect("legacy delegation call must receive an immediate rejection") + else { + panic!("expected a legacy delegation failure response") + }; + assert!(!response.success); + } + + let creation_source = create_history_rollout( + &app.config, + ThreadHistoryMode::Legacy, + "Background task source", + )?; + app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + creation_source, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let project: codex_app_server_protocol::ProjectCreateResponse = app_server + .request_handle() + .request_typed(ClientRequest::ProjectCreate { + request_id: AppServerRequestId::String("create-source-project".to_string()), + params: codex_app_server_protocol::ProjectCreateParams { + name: "Source project".to_string(), + roots: vec![codex_app_server_protocol::ProjectRoot { + path: app.config.cwd.clone(), + }], + metadata: None, + idempotency_key: "source-project".to_string(), + }, + }) + .await?; + let _: codex_app_server_protocol::ThreadMetadataUpdateResponse = app_server + .request_handle() + .request_typed(ClientRequest::ThreadMetadataUpdate { + request_id: AppServerRequestId::String("assign-source-project".to_string()), + params: codex_app_server_protocol::ThreadMetadataUpdateParams { + thread_id: creation_source.to_string(), + project_id: Some(project.project.id.clone()), + daybreak_enabled: None, + git_info: None, + }, + }) + .await?; + let source_settings: codex_app_server_protocol::ThreadResumeResponse = app_server + .request_handle() + .request_typed(ClientRequest::ThreadResume { + request_id: AppServerRequestId::String("read-source-sandbox".to_string()), + params: codex_app_server_protocol::ThreadResumeParams { + thread_id: creation_source.to_string(), + ..codex_app_server_protocol::ThreadResumeParams::default() + }, + }) + .await?; + assert!(source_settings.active_permission_profile.is_none()); + let source_sandbox = serde_json::to_value(source_settings.sandbox)?; + spawn_approved_task_tool_call( + &app, + &app_server, + AppServerRequestId::Integer(103), + codex_app_server_protocol::DynamicToolCallParams { + thread_id: creation_source.to_string(), + turn_id: "turn-1".to_string(), + call_id: "call-4".to_string(), + namespace: Some("codex_tui".to_string()), + tool: "create_thread".to_string(), + arguments: serde_json::json!({ + "prompt": "Check
& report", + "title": "Unavailable name" + }), + }, + ); + let registration = + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("background task registration event"); + let AppEvent::DynamicToolThreadStarted { + thread_id: created_thread_id, + task_tools_available, + registered, + } = registration + else { + panic!("expected background task registration before its first turn: {registration:?}") + }; + assert!(recorded_params(&requests, "turn/start").is_empty()); + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::DynamicToolThreadStarted { + thread_id: created_thread_id, + task_tools_available, + registered, + }, + )) + .await?; + assert!( + app.agents_overview + .dispatched_requests + .contains_key(&created_thread_id) + ); + assert!(app_server.task_tools_available(created_thread_id)); + let AppEvent::DynamicToolCallCompleted { response, .. } = + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("background task creation completion") + else { + panic!("expected a background task completion event") + }; + assert!(response.success, "{response:?}"); + assert_eq!( + recorded_params(&requests, "thread/start") + .last() + .expect("background task creation")["projectId"], + project.project.id + ); + let turn = recorded_params(&requests, "turn/start") + .pop() + .expect("background task turn request"); + assert_eq!(turn["input"], serde_json::json!([])); + assert_eq!( + turn["toolOutput"], + serde_json::json!({ + "name": "create_thread", + "namespace": "codex_tui", + "output": format!( + "\n {creation_source}\n Check <main> & report\n" + ) + }) + ); + assert_eq!(turn["sandboxPolicy"], source_sandbox); + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new(exec_approval_request( + created_thread_id, + "turn-2", + "item-1", + /*approval_id*/ None, + ))), + ) + .await; + assert_eq!( + app.agents_overview.dispatched_requests[&created_thread_id].len(), + 1 + ); + + spawn_approved_task_tool_call( + &app, + &app_server, + AppServerRequestId::Integer(104), + codex_app_server_protocol::DynamicToolCallParams { + thread_id: thread_id.clone(), + turn_id: "turn-1".to_string(), + call_id: "call-5".to_string(), + namespace: Some("codex_tui".to_string()), + tool: "send_message_to_thread".to_string(), + arguments: serde_json::json!({ + "threadId": creation_source, + "prompt": "Follow & report" + }), + }, + ); + let AppEvent::DynamicToolThreadStarted { + thread_id: continued_thread_id, + task_tools_available, + registered, + } = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("follow-up task registration event") + else { + panic!("expected follow-up task registration before its next turn") + }; + assert_eq!(continued_thread_id, creation_source); + assert_eq!(recorded_params(&requests, "turn/start").len(), 1); + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::DynamicToolThreadStarted { + thread_id: continued_thread_id, + task_tools_available, + registered, + }, + )) + .await?; + let AppEvent::DynamicToolCallCompleted { response, .. } = + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("follow-up task completion") + else { + panic!("expected a follow-up task completion event") + }; + assert!(response.success, "{response:?}"); + let turn = &recorded_params(&requests, "turn/start")[1]; + assert_eq!(turn["input"], serde_json::json!([])); + assert_eq!( + turn["toolOutput"], + serde_json::json!({ + "name": "send_message_to_thread", + "namespace": "codex_tui", + "output": format!( + "\n {thread_id}\n Follow <up> & report\n" + ) + }) + ); + + app.dynamic_tool_tasks.insert( + AppServerRequestId::Integer(105), + (thread_id, tokio::spawn(std::future::pending::<()>())), + ); + assert_matches!( + app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst) + .await, + AppRunControl::Exit(ExitReason::UserRequested) + ); + let cancelled = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + loop { + if let Some(response) = recorded_params(&requests, "server/request/response") + .into_iter() + .find(|response| response["success"] == false) + { + break response; + } + tokio::task::yield_now().await; + } + }) + .await?; + assert_eq!(cancelled["success"], false); + assert!(app.dynamic_tool_tasks.is_empty()); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> { + let (mut app, codex_home) = make_history_test_app().await?; + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(100); + let thread_id = create_fake_paginated_rollout( + codex_home.path(), + "2026-01-02T00-00-00", + "2026-01-02T00:00:00Z", + "older visible prompt", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .map_err(|error| color_eyre::eyre::eyre!("failed to create paginated rollout: {error}"))?; + let thread_id = ThreadId::from_string(&thread_id)?; + let path = rollout_path( + codex_home.path(), + "2026-01-02T00-00-00", + &thread_id.to_string(), + ); + let mut records = std::fs::read_to_string(&path)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + let user_item = |id: &str, text: &str| { + TurnItem::UserMessage(UserMessageItem { + id: id.to_string(), + client_id: None, + content: vec![CoreUserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + }) + }; + let mut items = vec![ + user_item("older-visible-prompt", "older visible prompt"), + TurnItem::EnteredReviewMode(EnteredReviewModeItem { + id: "cross-page-review-start".to_string(), + target: ReviewTarget::UncommittedChanges, + user_facing_hint: "review started".to_string(), + }), + user_item("hidden-review-prompt", "hidden cross-page review prompt"), + ]; + items.extend((0..97).map(|index| { + TurnItem::AgentMessage(AgentMessageItem { + id: format!("review-output-{index}"), + content: vec![AgentMessageContent::Text { + text: format!("review output {index}"), + }], + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }) + })); + items.extend([ + TurnItem::ExitedReviewMode(ExitedReviewModeItem { + id: "cross-page-review-end".to_string(), + review_output: None, + }), + user_item("newer-visible-prompt", "newer visible prompt"), + ]); + let events = std::iter::once(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "cross-page-review-turn".to_string(), + root_turn_id: None, + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })) + .chain(items.into_iter().map(|item| { + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: "cross-page-review-turn".to_string(), + item, + started_at_ms: None, + completed_at_ms: 0, + }) + })); + for event in events { + records.push(serde_json::json!({ + "timestamp": "2026-01-02T00:00:00Z", + "ordinal": records.len(), + "type": "event_msg", + "payload": serde_json::to_value(event)?, + })); + } + let records = records + .into_iter() + .map(|record| record.to_string()) + .collect::>() + .join("\n"); + std::fs::write(path, format!("{records}\n"))?; + let (mut app_server, _requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let started = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let initial_cells = crate::thread_transcript::thread_items_to_transcript_cells( + Some(thread_id), + &app.config.cwd, + started.turns.iter().flat_map(|turn| turn.items.clone()), + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(&app.config), + ); + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.transcript_cells = initial_cells; + assert_eq!( + app.transcript_cells + .iter() + .filter_map(|cell| cell.as_any().downcast_ref::()) + .map(|user| user.message.as_str()) + .collect::>(), + vec!["hidden cross-page review prompt", "newer visible prompt"] + ); + app.backtrack.overlay_preview_active = true; + app.backtrack.nth_user_message = 1; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.open_transcript_overlay(&mut tui); + app.apply_backtrack_selection_internal(app.backtrack.nth_user_message); + let cursor = app_server + .begin_older_history_page(thread_id) + .expect("review-mode marker should remain on an older page"); + let request_id = app_server.next_request_id(); + let page: ThreadItemsListResponse = app_server + .request_handle() + .request_typed(ClientRequest::ThreadItemsList { + request_id, + params: ThreadItemsListParams { + thread_id: thread_id.to_string(), + turn_id: None, + cursor: Some(cursor.clone()), + limit: Some(crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT), + sort_direction: Some(SortDirection::Desc), + }, + }) + .await?; + app.handle_older_history_page(&mut tui, &mut app_server, thread_id, &cursor, Ok(page)) + .await?; + + let visible_user_messages = app + .transcript_cells + .iter() + .filter_map(|cell| cell.as_any().downcast_ref::()) + .map(|user| user.message.clone()) + .collect::>(); + assert_eq!( + visible_user_messages, + vec!["older visible prompt", "newer visible prompt"] + ); + assert_eq!(app.backtrack.nth_user_message, 1); + let area = ratatui::layout::Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12, + ); + let mut buffer = Buffer::empty(area); + let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() else { + panic!("expected a transcript overlay"); + }; + overlay.render(area, &mut buffer); + let highlighted_output = area + .positions() + .filter(|position| { + buffer[*position] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + }) + .map(|position| buffer[position].symbol()) + .collect::(); + let highlighted_message = visible_user_messages + .iter() + .find(|message| highlighted_output.contains(message.as_str())) + .expect("the selected user message should remain highlighted"); + insta::assert_snapshot!( + format!( + "{}\nhighlighted: {highlighted_message}", + visible_user_messages.join("\n") + ), + @r" + older visible prompt + newer visible prompt + highlighted: newer visible prompt + "); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn transcript_home_loads_every_older_history_page() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(2); + let thread_id = create_fake_paginated_rollout( + codex_home.path(), + "2026-01-02T00-00-00", + "2026-01-02T00:00:00Z", + "multi-page transcript", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .map_err(|error| color_eyre::eyre::eyre!("failed to create paginated rollout: {error}"))?; + let thread_id = ThreadId::from_string(&thread_id)?; + let path = rollout_path( + codex_home.path(), + "2026-01-02T00-00-00", + &thread_id.to_string(), + ); + let mut records = std::fs::read_to_string(&path)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + let events = std::iter::once(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "multi-page-turn".to_string(), + root_turn_id: None, + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })) + .chain((0..305).map(|index| { + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: "multi-page-turn".to_string(), + item: TurnItem::AgentMessage(AgentMessageItem { + id: format!("history-item-{index}"), + content: vec![AgentMessageContent::Text { + text: format!("history output {index}"), + }], + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }), + started_at_ms: None, + completed_at_ms: 0, + }) + })); + for event in events { + records.push(serde_json::json!({ + "timestamp": "2026-01-02T00:00:00Z", + "ordinal": records.len(), + "type": "event_msg", + "payload": serde_json::to_value(event)?, + })); + } + let records = records + .into_iter() + .map(|record| record.to_string()) + .collect::>() + .join("\n"); + std::fs::write(path, format!("{records}\n"))?; + + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let started = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let initial_cells = crate::thread_transcript::thread_items_to_transcript_cells( + Some(thread_id), + &app.config.cwd, + started.turns.iter().flat_map(|turn| turn.items.clone()), + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(&app.config), + ); + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.transcript_cells = initial_cells; + while app_event_rx.try_recv().is_ok() {} + let initial_turn_requests = recorded_params(&requests, "thread/turns/list").len(); + let initial_item_requests = recorded_params(&requests, "thread/items/list").len(); + let export_path = codex_home.path().join("complete-export.md"); + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + app.chat_widget.insert_str("queued after export"); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let mut tui = crate::tui::test_support::make_test_tui()?; + // Keep the large dispatcher future off the Windows test thread's stack. + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ExportTranscript { + destination: TranscriptExportDestination::File(export_path.clone()), + }, + )) + .await?; + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + let markdown = std::fs::read_to_string(export_path)?; + assert!( + (0..305) + .map(|index| format!("history output {index}")) + .eq(markdown.lines().filter(|line| line.starts_with("history"))) + ); + assert!( + recorded_params(&requests, "thread/turns/list")[initial_turn_requests..] + .iter() + .all(|params| params["itemsView"] == "notLoaded") + ); + assert!( + recorded_params(&requests, "thread/items/list")[initial_item_requests..] + .iter() + .all(|params| { + params["limit"] == crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT + }) + ); + app.scrollback_has_older_history = app_server.has_older_history(thread_id); + assert!(app.scrollback_has_older_history); + while app_event_rx.try_recv().is_ok() {} + let initial_page_requests = recorded_params(&requests, "thread/items/list").len(); + app.open_transcript_overlay(&mut tui); + + app.handle_backtrack_overlay_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)), + ) + .await?; + while app_server.has_older_history(thread_id) { + let event = tokio::time::timeout(Duration::from_secs(5), app_event_rx.recv()) + .await? + .ok_or_else(|| color_eyre::eyre::eyre!("history event channel closed"))?; + if matches!(event, AppEvent::OlderThreadHistoryLoaded { .. }) { + Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + } + } + + assert!(recorded_params(&requests, "thread/items/list").len() >= initial_page_requests + 3); + assert!(app.transcript_cells.iter().any(|cell| { + cell.display_lines(/*width*/ 80) + .iter() + .any(|line| line.to_string().contains("history output 0")) + })); + let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() else { + panic!("expected transcript overlay after Home navigation"); + }; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12, + ); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + let visible = (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + assert!(visible.contains("history output 0"), "{visible}"); + assert!(!visible.contains("history output 304"), "{visible}"); + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let legacy_thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "legacy history")?; + let paginated_thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Paginated, + "paginated history", + )?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::LegacyOnly, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + + let started = app_server.start_thread(&app.config).await?; + let resumed = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + legacy_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let forked = app_server + .fork_thread(&app.local_settings, app.config.clone(), legacy_thread_id) + .await?; + + assert_ne!(started.session.thread_id, legacy_thread_id); + assert_eq!(resumed.session.thread_id, legacy_thread_id); + assert_ne!(forked.session.thread_id, legacy_thread_id); + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 2); + assert_eq!(starts[0]["historyMode"], "paginated"); + assert_eq!(starts[1]["historyMode"], serde_json::Value::Null); + + for method in ["thread/resume", "thread/fork"] { + let params = recorded_params(&requests, method); + assert_eq!(params.len(), 1, "legacy {method} must not be reprobed"); + assert_ne!(params[0]["excludeTurns"], true); + } + assert!(recorded_params(&requests, "thread/turns/list").is_empty()); + assert!(recorded_params(&requests, "thread/items/list").is_empty()); + + let initial_read_count = recorded_params(&requests, "thread/read").len(); + let exported = crate::app::transcript_export::load_export_transcript( + &mut app_server, + paginated_thread_id, + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(&app.config), + vec![Arc::new(PlainHistoryCell::new(vec!["visible".into()]))], + ) + .await + .map_err(|error| color_eyre::eyre::eyre!(error))?; + assert_eq!(exported[0].raw_lines()[0].to_string(), "visible"); + assert!( + recorded_params(&requests, "thread/read")[initial_read_count..] + .iter() + .any(|params| params["includeTurns"] == true) + ); + assert_eq!(recorded_params(&requests, "thread/turns/list").len(), 1); + + let (_status_sender, status_updates) = tokio::sync::broadcast::channel(/*capacity*/ 1); + let response = crate::dynamic_tools::execute( + app_server.request_handle(), + codex_app_server_protocol::DynamicToolCallParams { + thread_id: started.session.thread_id.to_string(), + turn_id: "source-turn".to_string(), + call_id: "legacy-wait".to_string(), + namespace: Some(crate::dynamic_tools::NAMESPACE.to_string()), + tool: "wait_threads".to_string(), + arguments: serde_json::json!({ + "targets": [{"threadId": legacy_thread_id}], + "timeoutMs": 0 + }), + }, + codex_app_server_protocol::ThreadStartParams::default(), + status_updates, + /*app_event_tx*/ None, + ) + .await; + assert!(response.success, "{response:?}"); + assert!( + recorded_params(&requests, "thread/read") + .iter() + .any(|params| { + params["threadId"] == legacy_thread_id.to_string() && params["includeTurns"] == true + }) + ); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_legacy_history_start_retries_unsupported_paginated_variant() -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::LegacyOnlyUnsupportedVariant, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + + app_server.start_thread(&app.config).await?; + + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 2); + assert_eq!(starts[0]["historyMode"], "paginated"); + assert_eq!(starts[1]["historyMode"], serde_json::Value::Null); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LegacyHistoryRequest { + Resume, + Fork, +} + +async fn assert_remote_legacy_history_retry(request: LegacyHistoryRequest) -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let legacy_thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "legacy history")?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::LegacyOnly, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + + let method = match request { + LegacyHistoryRequest::Resume => { + let resumed = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + legacy_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(resumed.session.thread_id, legacy_thread_id); + "thread/resume" + } + LegacyHistoryRequest::Fork => { + let forked = app_server + .fork_thread(&app.local_settings, app.config.clone(), legacy_thread_id) + .await?; + assert_ne!(forked.session.thread_id, legacy_thread_id); + "thread/fork" + } + }; + let attempts = recorded_params(&requests, method); + if request == LegacyHistoryRequest::Resume { + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0]["excludeTurns"], true); + } else { + assert_eq!(attempts.len(), 1); + } + assert_ne!( + attempts.last().expect("history request")["excludeTurns"], + true + ); + assert!(recorded_params(&requests, "thread/turns/list").is_empty()); + assert!(recorded_params(&requests, "thread/items/list").is_empty()); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn remote_legacy_history_resume_retries_generic_method_not_found() -> Result<()> { + assert_remote_legacy_history_retry(LegacyHistoryRequest::Resume).await +} + +#[tokio::test] +async fn remote_legacy_history_fork_avoids_unsupported_fields() -> Result<()> { + assert_remote_legacy_history_retry(LegacyHistoryRequest::Fork).await +} + +#[tokio::test] +async fn paginated_fork_survives_post_response_hydration_failure() -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let parent_thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Paginated, + "paginated fork parent", + )?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::ForkHydrationFails, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + + let started = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + parent_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(started.session.thread_id, parent_thread_id); + + let forked = app_server + .fork_thread(&app.local_settings, app.config.clone(), parent_thread_id) + .await?; + + assert_ne!(forked.session.thread_id, parent_thread_id); + assert_eq!(recorded_params(&requests, "thread/fork").len(), 1); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcript() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(8); + let thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Paginated, + "scrollback pagination", + )?; + let path = rollout_path( + codex_home.path(), + "2026-01-02T00-00-00", + &thread_id.to_string(), + ); + let mut records = std::fs::read_to_string(&path)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + let events = std::iter::once(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "scrollback-pagination-turn".to_string(), + root_turn_id: None, + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + })) + .chain((0..120).map(|index| { + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: "scrollback-pagination-turn".to_string(), + item: TurnItem::AgentMessage(AgentMessageItem { + id: format!("scrollback-item-{index}"), + content: vec![AgentMessageContent::Text { + text: format!("scrollback output {index}"), + }], + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }), + started_at_ms: None, + completed_at_ms: 0, + }) + })); + for event in events { + records.push(serde_json::json!({ + "timestamp": "2026-01-02T00:00:00Z", + "ordinal": records.len(), + "type": "event_msg", + "payload": serde_json::to_value(event)?, + })); + } + let records = records + .into_iter() + .map(|record| record.to_string()) + .collect::>() + .join("\n"); + std::fs::write(path, format!("{records}\n"))?; + + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let started = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let mut initial_cells = crate::thread_transcript::thread_items_to_transcript_cells( + Some(thread_id), + &app.config.cwd, + started.turns.iter().flat_map(|turn| turn.items.clone()), + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(&app.config), + ); + initial_cells.insert( + /*index*/ 0, + Arc::new(crate::history_cell::new_session_info( + &app.config, + &app.local_settings, + started.session.model.as_str(), + &started.session, + /*is_first_event*/ false, + Some("This is a test announcement".to_string()), + /*auth_plan*/ None, + /*show_fast_status*/ false, + )), + ); + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.transcript_cells = initial_cells; + app.scrollback_has_older_history = app_server.has_older_history(thread_id); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(32); + let initial_cell_count = app.transcript_cells.len(); + let initial_page_requests = recorded_params(&requests, "thread/items/list").len(); + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.scrollback_has_older_history = false; + app.handle_key_event( + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL), + ) + .await; + assert!(app.scrollback_has_older_history); + if let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() { + overlay.handle_event( + &mut tui, + TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)), + )?; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 100, /*height*/ 16, + ); + let render_overlay = |overlay: &mut crate::pager_overlay::TranscriptOverlay| { + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + }; + let partial = render_overlay(overlay); + assert!(partial.contains("Earlier messages are available — scroll up to load them")); + assert!(!partial.contains("OpenAI Codex")); + assert!(!partial.contains("This is a test announcement")); + assert!(!partial.contains('%')); + + overlay.set_history_state(crate::pager_overlay::TranscriptHistoryState::LoadingOlder); + let loading = render_overlay(overlay); + assert!(loading.contains("Loading earlier messages...")); + assert!(!loading.contains("OpenAI Codex")); + assert!(!loading.contains('%')); + } else { + panic!("expected transcript overlay"); + } + app.close_transcript_overlay(&mut tui); + + let terminal_width = tui.terminal.last_known_screen_size.into(); + app.reflow_transcript_now(&mut tui, terminal_width)?; + let request = loop { + match app_event_rx.recv().await { + Some(event @ AppEvent::RequestOlderScrollbackHistory { .. }) => break event, + Some(_) => {} + None => panic!("scrollback refill request channel closed"), + } + }; + // Keep the large dispatcher future off the Windows test thread's stack. + Box::pin(app.handle_event(&mut tui, &mut app_server, request)).await?; + let loaded = loop { + match app_event_rx.recv().await { + Some(event @ AppEvent::OlderThreadHistoryLoaded { .. }) => break event, + Some(_) => {} + None => panic!("older history page channel closed"), + } + }; + Box::pin(app.handle_event(&mut tui, &mut app_server, loaded)).await?; + + assert!(app.overlay.is_none()); + assert!(app.transcript_cells.len() > initial_cell_count); + assert_eq!( + recorded_params(&requests, "thread/items/list").len(), + initial_page_requests + 1 + ); + assert_eq!( + app.render_transcript_lines_for_reflow(/*width*/ 80) + .lines + .len(), + 32 + ); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { + let (app, _codex_home) = make_history_test_app().await?; + let paginated_thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Paginated, + "paginated visible history", + )?; + let legacy_thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Legacy, + "legacy visible history", + )?; + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + + app_server.remember_thread_history_mode(paginated_thread_id, ThreadHistoryMode::Legacy); + let resumed = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + paginated_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(resumed.session.thread_id, paginated_thread_id); + assert!(recorded_params(&requests, "thread/read").is_empty()); + let resume_requests = recorded_params(&requests, "thread/resume"); + assert_eq!(resume_requests.len(), 1); + assert_eq!(resume_requests[0]["excludeTurns"], true); + let cells = crate::thread_transcript::load_session_transcript( + &mut app_server, + paginated_thread_id, + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(&app.config), + ) + .await?; + assert!(!cells.is_empty()); + app_server + .fork_thread(&app.local_settings, app.config.clone(), paginated_thread_id) + .await?; + let mut side_config = app.config.clone(); + side_config.ephemeral = true; + app_server + .fork_side_thread( + &crate::local_settings::LocalSettings::from(&side_config), + side_config, + paginated_thread_id, + ) + .await?; + + let paginated_reads = recorded_params(&requests, "thread/read"); + assert!(!paginated_reads.is_empty()); + assert!( + paginated_reads + .iter() + .all(|params| params["includeTurns"] != true), + "paginated workflows requested full history: {paginated_reads:?}" + ); + assert!(!recorded_params(&requests, "thread/turns/list").is_empty()); + assert!(!recorded_params(&requests, "thread/items/list").is_empty()); + + let previous_read_count = paginated_reads.len(); + let preview = crate::resume_picker::load_transcript_preview( + &mut app_server, + legacy_thread_id, + Some(&app.config), + ) + .await?; + assert!(!preview.is_empty()); + let preview_reads = recorded_params(&requests, "thread/read"); + let preview_include_turns = preview_reads[previous_read_count..] + .iter() + .map(|params| params["includeTurns"].as_bool().unwrap_or(false)) + .collect::>(); + assert_eq!(preview_include_turns, vec![false]); + + let previous_read_count = preview_reads.len(); + crate::thread_transcript::load_session_transcript( + &mut app_server, + legacy_thread_id, + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(&app.config), + ) + .await?; + let legacy_reads = recorded_params(&requests, "thread/read"); + let legacy_include_turns = legacy_reads[previous_read_count..] + .iter() + .map(|params| params["includeTurns"].as_bool().unwrap_or(false)) + .collect::>(); + assert_eq!(legacy_include_turns, vec![false, true]); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn agents_overview_stop_uses_history_mode_for_turn_lookup() -> Result<()> { + let (mut app, _codex_home) = make_history_test_app().await?; + let paginated_thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Paginated, + "paginated background task", + )?; + let cases = [ + (paginated_thread_id, vec![false], 1), + ( + create_history_rollout( + &app.config, + ThreadHistoryMode::Legacy, + "legacy background task", + )?, + vec![false, true], + 0, + ), + ]; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + + for (thread_id, expected_include_turns, expected_turn_page_count) in cases { + let previous_reads = recorded_params(&requests, "thread/read"); + let previous_turn_page_count = recorded_params(&requests, "thread/turns/list").len(); + + app.stop_agents_overview_thread(&mut app_server, thread_id) + .await; + + let reads = recorded_params(&requests, "thread/read"); + let include_turns = reads[previous_reads.len()..] + .iter() + .map(|params| params["includeTurns"].as_bool().unwrap_or(false)) + .collect::>(); + assert_eq!(include_turns, expected_include_turns); + assert_eq!( + recorded_params(&requests, "thread/turns/list").len() - previous_turn_page_count, + expected_turn_page_count + ); + } + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable() -> Result<()> { + for (capabilities, expected_sort_keys) in [ + ( + HistoryCapabilities::LegacyOnly, + vec!["recency_at", "recency_at", "updated_at", "updated_at"], + ), + ( + HistoryCapabilities::ThreadListFails, + vec!["recency_at", "recency_at", "recency_at", "recency_at"], + ), + ] { + let (mut app, _codex_home) = make_history_test_app().await?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + capabilities, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let started = app_server.start_thread(&app.config).await?; + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: test_path_buf("/tmp/unused.sock").abs(), + }, + }; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + for attempt in 0..2 { + if attempt == 0 { + app.refresh_agents_overview_threads(&app_server); + } else { + app.open_agents_overview(&app_server); + } + let Some(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }) = + tokio::time::timeout(Duration::from_secs(10), rx.recv()).await? + else { + panic!("expected overview result") + }; + app.apply_agents_overview_thread_refresh(&app_server, request_id, result); + assert_eq!( + app.agents_overview + .threads + .keys() + .copied() + .collect::>(), + vec![started.session.thread_id] + ); + assert_eq!( + app.agents_overview.initialized, + capabilities != HistoryCapabilities::ThreadListFails || attempt > 0 + ); + if attempt == 0 { + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new( + ServerNotification::ThreadStatusChanged( + codex_app_server_protocol::ThreadStatusChangedNotification { + thread_id: started.session.thread_id.to_string(), + status: codex_app_server_protocol::ThreadStatus::Idle, + }, + ), + )), + ) + .await; + assert!(app.agents_overview.request_id.is_none()); + } + } + let list_requests = recorded_params(&requests, "thread/list"); + let mut sort_keys = list_requests + .iter() + .map(|params| params["sortKey"].as_str().unwrap()) + .collect::>(); + sort_keys.sort_unstable(); + assert_eq!(sort_keys, expected_sort_keys); + app_server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn agents_overview_stop_uses_full_history_after_legacy_negotiation() -> Result<()> { + let (mut app, _codex_home) = make_history_test_app().await?; + let thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Paginated, + "paginated background task", + )?; + let (mut app_server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::LegacyOnly, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + app_server.start_thread(&app.config).await?; + + app.stop_agents_overview_thread(&mut app_server, thread_id) + .await; + + let include_turns = recorded_params(&requests, "thread/read") + .into_iter() + .map(|params| params["includeTurns"].as_bool().unwrap_or(false)) + .collect::>(); + assert_eq!(include_turns, vec![false, true]); + assert!(recorded_params(&requests, "thread/turns/list").is_empty()); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn cold_paginated_subagent_transcript_excludes_inherited_parent_history() -> Result<()> { + let (app, codex_home) = make_history_test_app().await?; + let parent_thread_id = create_history_rollout( + &app.config, + ThreadHistoryMode::Paginated, + "parent-only paginated history", + )?; + let child_timestamp = "2026-01-02T00-00-01"; + let child_thread_id = ThreadId::from_string( + &create_fake_parented_rollout_with_source( + codex_home.path(), + child_timestamp, + "2026-01-02T00:00:01Z", + "child-only paginated history", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some( + AgentPath::try_from("/root/worker").map_err(color_eyre::eyre::Report::msg)?, + ), + agent_nickname: Some("worker".to_string()), + agent_role: Some("worker".to_string()), + }), + parent_thread_id.into(), + parent_thread_id, + ) + .map_err(|err| color_eyre::eyre::eyre!("failed to create subagent rollout: {err}"))?, + )?; + let child_rollout_path = rollout_path( + codex_home.path(), + child_timestamp, + &child_thread_id.to_string(), + ); + let mut child_lines = std::fs::read_to_string(&child_rollout_path)? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + let mut meta = child_lines.remove(/*index*/ 0); + meta["payload"]["history_mode"] = serde_json::json!("paginated"); + meta["payload"]["subagent_history_start_ordinal"] = serde_json::json!(3); + meta["ordinal"] = serde_json::json!(0); + for (index, line) in child_lines.iter_mut().enumerate() { + line["ordinal"] = serde_json::json!(index + 3); + } + let rollout_record = |ordinal: usize, kind: &str, payload: serde_json::Value| { + serde_json::json!({ + "timestamp": "2026-01-02T00:00:01Z", + "ordinal": ordinal, + "type": kind, + "payload": payload, + }) + }; + let inherited_response = rollout_record( + /*ordinal*/ 1, + "response_item", + serde_json::json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "parent-only paginated history", + }], + }), + ); + let inherited_event = rollout_record( + /*ordinal*/ 2, + "event_msg", + serde_json::json!({ + "type": "user_message", + "message": "parent-only paginated history", + "kind": "plain", + }), + ); + let lines = std::iter::once(meta) + .chain([inherited_response, inherited_event]) + .chain(child_lines) + .chain([ + rollout_record( + /*ordinal*/ 5, + "event_msg", + serde_json::json!({ + "type": "task_started", + "turn_id": "child-visible-turn", + "model_context_window": null, + }), + ), + rollout_record( + /*ordinal*/ 6, + "event_msg", + serde_json::json!({ + "type": "item_completed", + "thread_id": child_thread_id, + "turn_id": "child-visible-turn", + "item": { + "type": "UserMessage", + "id": "child-visible-user", + "content": [{ + "type": "text", + "text": "child-only paginated history", + }], + }, + }), + ), + ]) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + std::fs::write(child_rollout_path, format!("{lines}\n"))?; + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + + let resumed = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + child_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let child_turn_page = app_server + .thread_turns_page(child_thread_id, /*cursor*/ None) + .await?; + let child_item_page = app_server + .thread_items_page( + child_thread_id, + /*turn_id*/ None, + /*cursor*/ None, + /*limit*/ 16, + ) + .await?; + let [child_turn] = child_turn_page.data.as_slice() else { + panic!("paginated subagent should expose exactly one child turn"); + }; + let [child_entry] = child_item_page.data.as_slice() else { + panic!("paginated subagent should expose exactly one child message"); + }; + let ThreadItem::UserMessage { content, .. } = &child_entry.item else { + panic!("paginated subagent should expose its child user message"); + }; + assert_eq!(resumed.session.thread_id, child_thread_id); + assert_eq!(child_entry.turn_id, child_turn.id); + assert_eq!( + content, + &[UserInput::Text { + text: "child-only paginated history".to_string(), + text_elements: Vec::new(), + }], + ); + assert_eq!( + resumed + .turns + .iter() + .flat_map(|turn| turn.items.iter()) + .collect::>(), + vec![&child_entry.item], + ); + + let cells = crate::thread_transcript::load_session_transcript( + &mut app_server, + child_thread_id, + crate::thread_transcript::RawReasoningVisibility::Hidden, + Some(&app.config), + ) + .await?; + let visible_history = cells + .iter() + .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80))) + .collect::>() + .join("\n"); + assert!(visible_history.contains("child-only paginated history")); + assert!(!visible_history.contains("parent-only paginated history")); + assert!( + recorded_params(&requests, "thread/read") + .iter() + .all(|params| params["includeTurns"] != true) + ); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn managed_worktree_transitions_bind_owner_and_preserve_only_fork_history() -> Result<()> { + use crate::app_event::ManagedWorktreeMode; + use std::fs; + use std::process::Command; + + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let root = tempdir()?; + let home = dunce::canonicalize(root.path())?.join("home"); + let source = dunce::canonicalize(root.path())?.join("source"); + let project_pool = dunce::canonicalize(root.path())?.join("project-pool"); + fs::create_dir_all(&home)?; + fs::create_dir_all(source.join(".codex"))?; + fs::write(home.join("config.toml"), "[features]\nworktrees = true\n")?; + crate::legacy_core::config::set_project_trust_level( + &home, + &source, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + let destination = format!( + r#"developer_instructions = "committed policy" +model = "gpt-5.2" +model_reasoning_effort = "high" +[desktop] +git-worktree-root = {} +"#, + toml::Value::String(project_pool.display().to_string()), + ); + fs::write(source.join(".codex/config.toml"), destination)?; + fs::write(source.join("AGENTS.md"), "committed worktree instructions")?; + for args in [ + vec!["init", "--quiet"], + vec!["add", "."], + vec!["commit", "--quiet", "--no-gpg-sign", "-m", "initial"], + ] { + let result = Command::new("git") + .current_dir(&source) + .args([ + "-c", + "user.name=Worktree Test", + "-c", + "user.email=test@example.invalid", + ]) + .args(args) + .output()?; + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + } + fs::write( + source.join(".codex/config.toml"), + r#"developer_instructions = "dirty policy" +model = "gpt-5.4" +model_reasoning_effort = "low" +"#, + )?; + fs::write(source.join("AGENTS.md"), "dirty source instructions")?; + app.config.codex_home = home.clone().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(home.clone().abs()); + app.config.cwd = source.clone().abs(); + app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write()); + app.config = app.rebuild_config_for_cwd(source.clone()).await?; + app.chat_widget + .handle_thread_session_quiet(test_thread_session(ThreadId::new(), source.clone())); + let (mut server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + assert_eq!( + ( + app.chat_widget.current_model(), + app.config.model_provider_id.as_str(), + app.chat_widget.current_reasoning_effort() + ), + ("gpt-5.4", "openai", Some(ReasoningEffortConfig::Low)) + ); + // Provider selection belongs to host config, not project config. + let host_config = home.join("config.toml"); + let contents = fs::read_to_string(&host_config)?; + fs::write( + host_config, + format!("model_provider = \"ollama\"\n{contents}"), + )?; + let original = app.chat_widget.thread_id().expect("original thread"); + server.thread_inject_items(original, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "managed fork history"}] + }))?]).await?; + let manager = codex_worktree::WorktreeManager::new( + codex_worktree::WorktreeSettings::for_cli(&home, /*desktop*/ None) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + ); + let mut browser_entries = Vec::new(); + requests.lock().expect("request recorder lock").clear(); + app.handle_event( + &mut tui, + &mut server, + AppEvent::StartManagedWorktree { + mode: ManagedWorktreeMode::Fork, + name: None, + }, + ) + .await?; + complete_managed_worktree_creation(&mut app, &mut tui, &mut server, &mut events).await?; + assert_eq!(app.chat_widget.thread_id(), Some(original)); + assert!(recorded_params(&requests, "thread/fork").is_empty()); + let unused = manager + .list(&source) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))? + .pop() + .expect("unused checkout"); + browser_entries.push(crate::worktree_browser::Entry { + root: unused.root.clone(), + cwd: unused.cwd.clone(), + owner: crate::worktree_browser::Owner::None, + }); + assert_eq!( + manager + .owner(&unused.root) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + None + ); + let error = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 500))) + } + _ => None, + }) + .find(|message| message.contains("Cannot fork into this worktree")) + .expect("fork refusal"); + let (_, with_path) = error + .split_once("An unused checkout was created at ") + .expect("checkout marker"); + let (path, _) = with_path + .split_once("; remove it with") + .expect("cleanup marker"); + insta::assert_snapshot!(error.replace(path, ""), @"■ Cannot fork into this worktree because developer instructions differ. Start a new conversation instead. An unused checkout was created at ; remove it with `git worktree remove ` from the source repository."); + let retained = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 500))) + } + _ => None, + }) + .find(|message| message.contains("A checkout was retained at")) + .expect("post-allocation recovery message"); + let (_, with_path) = retained + .split_once("A checkout was retained at ") + .expect("retained checkout marker"); + let (path, _) = with_path + .split_once("; remove it with") + .expect("retained checkout cleanup marker"); + assert_eq!( + dunce::canonicalize(path)?, + dunce::canonicalize(&unused.root)? + ); + assert!(retained.contains("git worktree remove ")); + fs::write( + source.join(".codex/config.toml"), + r#"developer_instructions = "committed policy" +[features] +terminal_visualization_instructions = true +"#, + )?; + app.config = app.rebuild_config_for_cwd(source.clone()).await?; + requests.lock().expect("request recorder lock").clear(); + app.handle_event( + &mut tui, + &mut server, + AppEvent::StartManagedWorktree { + mode: ManagedWorktreeMode::Fork, + name: None, + }, + ) + .await?; + complete_managed_worktree_creation(&mut app, &mut tui, &mut server, &mut events).await?; + assert_eq!(app.chat_widget.thread_id(), Some(original)); + assert!(recorded_params(&requests, "thread/fork").is_empty()); + let feature_mismatch_checkout = manager + .list(&source) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))? + .into_iter() + .find(|checkout| checkout.cwd != unused.cwd) + .expect("feature mismatch leaves an unused checkout"); + browser_entries.push(crate::worktree_browser::Entry { + root: feature_mismatch_checkout.root, + cwd: feature_mismatch_checkout.cwd, + owner: crate::worktree_browser::Owner::None, + }); + while events.try_recv().is_ok() {} + app.handle_event( + &mut tui, + &mut server, + AppEvent::StartManagedWorktree { + mode: ManagedWorktreeMode::New, + name: None, + }, + ) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(original)); + drain_managed_worktree_start(&mut app, &mut server).await; + assert!(app.pending_managed_worktree_creation); + let created = loop { + let event = tokio::time::timeout(Duration::from_secs(15), events.recv()) + .await? + .ok_or_else(|| color_eyre::eyre::eyre!("worktree completion channel closed"))?; + if let AppEvent::ManagedWorktreeCreated(created) = event { + break created; + } + }; + let stale_checkout = created + .result + .as_ref() + .expect("created checkout") + .1 + .root + .clone(); + let stale_cwd = created + .result + .as_ref() + .expect("created checkout") + .1 + .cwd + .clone(); + app.primary_thread_id = Some(ThreadId::new()); + app.handle_event( + &mut tui, + &mut server, + AppEvent::ManagedWorktreeCreated(created), + ) + .await?; + let created = app + .pending_managed_worktree_created + .take() + .expect("deferred checkout completion"); + app.finish_managed_worktree(*created).await; + app.primary_thread_id = Some(original); + assert_eq!(app.chat_widget.thread_id(), Some(original)); + assert!(!app.pending_managed_worktree_creation); + let stale_error = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 500))) + } + _ => None, + }) + .find(|message| message.contains("A checkout was retained at")) + .expect("stale creation recovery message"); + assert!(stale_error.contains(&stale_checkout.display().to_string())); + assert!(stale_error.contains("git worktree remove ")); + assert_eq!( + manager + .owner(&stale_checkout) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + None + ); + browser_entries.push(crate::worktree_browser::Entry { + root: stale_checkout, + cwd: stale_cwd, + owner: crate::worktree_browser::Owner::None, + }); + for mode in [ManagedWorktreeMode::New, ManagedWorktreeMode::Fork] { + requests.lock().expect("request recorder lock").clear(); + let previous = app.chat_widget.thread_id(); + let previous_rollout = app.chat_widget.rollout_path().expect("previous rollout"); + app.handle_event( + &mut tui, + &mut server, + AppEvent::StartManagedWorktree { + mode, + name: Some(format!("{mode:?} worktree")), + }, + ) + .await?; + complete_managed_worktree_creation(&mut app, &mut tui, &mut server, &mut events).await?; + let replacement = app.chat_widget.thread_id().expect("replacement thread"); + assert_ne!(Some(replacement), previous); + assert_eq!( + app.chat_widget.thread_name(), + Some(format!("{mode:?} worktree")) + ); + let cwd = app.config.cwd.as_path().canonicalize()?; + let checkouts = manager + .list(&source) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + let checkout = checkouts + .iter() + .find(|checkout| checkout.cwd.canonicalize().ok().as_ref() == Some(&cwd)) + .expect("managed checkout"); + browser_entries.push(crate::worktree_browser::Entry { + root: checkout.root.clone(), + cwd: checkout.cwd.clone(), + owner: crate::worktree_browser::Owner::Unavailable(replacement), + }); + assert!(checkout.root.starts_with(home.join("worktrees"))); + assert!(!project_pool.exists()); + assert_eq!( + manager + .owner(&checkout.root) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + Some(replacement.to_string()) + ); + assert_eq!( + app.config.developer_instructions.as_deref(), + Some("committed policy") + ); + assert_eq!( + fs::read_to_string(checkout.cwd.join("AGENTS.md"))?, + "committed worktree instructions" + ); + let fork = matches!(mode, ManagedWorktreeMode::Fork); + let method = if fork { "thread/fork" } else { "thread/start" }; + let params = recorded_params(&requests, method); + assert_eq!(params.len(), 1); + assert_eq!( + [ + params[0]["model"].as_str(), + params[0]["modelProvider"].as_str(), + params[0]["config"]["model_reasoning_effort"].as_str(), + ], + [Some("gpt-5.2"), Some("ollama"), Some("high")] + ); + // Injection flushes and materializes a new thread's otherwise lazy rollout. + server.thread_inject_items(replacement, vec![serde_json::from_value(serde_json::json!({ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": format!("replacement persistence probe {mode:?}")}] + }))?]).await?; + let rollout = app.chat_widget.rollout_path().expect("replacement rollout"); + let mut history = fs::read(&rollout)?; + let metadata = codex_rollout::read_session_meta_line(&rollout).await?; + assert_eq!(metadata.meta.history_base.is_some(), fork); + if let Some(base) = metadata.meta.history_base { + assert!(fork, "New must not inherit history"); + assert_eq!(Some(base.thread_id), previous); + let parent = fs::read(&previous_rollout)?; + history.extend_from_slice( + parent + .get(..usize::try_from(base.end_byte_offset)?) + .expect("valid inherited prefix"), + ); + } + let history = String::from_utf8(history)?; + assert!(!history.contains("managed fork history")); + if fork { + assert!(history.contains("replacement persistence probe New")); + } + let attached = server + .thread_read(replacement, /*include_turns*/ false) + .await?; + assert_eq!(attached.cwd.as_path().canonicalize()?, cwd); + } + browser_entries.sort_by(|left, right| left.cwd.cmp(&right.cwd)); + let nested = source.join("browser-only"); + fs::create_dir(&nested)?; + assert_eq!( + crate::worktree_browser::list(home.clone(), nested) + .await + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + browser_entries + ); + let unowned = manager + .create(&codex_worktree::CreateWorktree { + source_cwd: source.clone(), + base: None, + }) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + browser_entries.push(crate::worktree_browser::Entry { + root: unowned.root.clone(), + cwd: unowned.cwd.clone(), + owner: crate::worktree_browser::Owner::None, + }); + browser_entries.sort_by(|left, right| left.cwd.cmp(&right.cwd)); + for owner in [None, Some("not-a-thread-uuid")] { + if let Some(owner) = owner { + manager + .bind_thread(&unowned.root, owner) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } + assert_eq!( + crate::worktree_browser::list(home.clone(), source.clone()) + .await + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?, + browser_entries + ); + } + let missing_owner = ThreadId::new(); + let missing_checkout = manager + .create(&codex_worktree::CreateWorktree { + source_cwd: source.clone(), + base: None, + }) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + manager + .bind_thread(&missing_checkout.root, &missing_owner.to_string()) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + let (browser_tx, mut browser_rx) = tokio::sync::mpsc::unbounded_channel(); + crate::worktree_browser::fetch( + crate::worktree_browser::Request { + id: uuid::Uuid::new_v4(), + cwd: source.clone(), + thread_id: Some(original), + }, + home.clone(), + server.request_handle(), + crate::app_event_sender::AppEventSender::new(browser_tx), + ); + let Some(AppEvent::ManagedWorktreesLoaded { + result: Ok(entries), + .. + }) = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 5), + browser_rx.recv(), + ) + .await? + else { + panic!("browser must return annotated worktrees"); + }; + assert_eq!( + entries + .iter() + .find(|entry| entry.root == missing_checkout.root) + .map(|entry| &entry.owner), + Some(&crate::worktree_browser::Owner::Unavailable(missing_owner)) + ); + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + let unsaved = app.chat_widget.thread_id().expect("unsaved thread"); + let path = app.chat_widget.rollout_path(); + assert!(path.is_none_or(|path| !rollout_path_is_resumable(&path))); + let completed = test_turn("completed-unsaved-turn", TurnStatus::Completed, Vec::new()); + app.thread_event_channels[&unsaved] + .store + .lock() + .await + .set_turns(vec![completed]); + for mode in [ManagedWorktreeMode::Fork, ManagedWorktreeMode::New] { + requests.lock().expect("request recorder lock").clear(); + app.handle_event( + &mut tui, + &mut server, + AppEvent::StartManagedWorktree { mode, name: None }, + ) + .await?; + drain_managed_worktree_start(&mut app, &mut server).await; + let is_new = mode == ManagedWorktreeMode::New; + if is_new { + complete_managed_worktree_creation(&mut app, &mut tui, &mut server, &mut events) + .await?; + } + assert_eq!(app.chat_widget.thread_id() != Some(unsaved), is_new); + assert_eq!(recorded_params(&requests, "thread/fork").len(), 0); + } + let saved_owner = entries + .iter() + .find_map(|entry| match &entry.owner { + crate::worktree_browser::Owner::Resumable(thread) => Some(thread.clone()), + _ => None, + }) + .expect("browser should resolve a saved owner"); + // Archive stops the writer; unarchive leaves a stored thread eligible for compression. + server.thread_archive(saved_owner.id).await?; + let saved_path = server + .thread_unarchive(saved_owner.id) + .await? + .path + .expect("saved rollout path"); + fs::OpenOptions::new() + .write(true) + .open(&saved_path)? + .set_times(std::fs::FileTimes::new().set_modified( + std::time::SystemTime::now() - std::time::Duration::from_secs(8 * 24 * 60 * 60), + ))?; + codex_rollout::spawn_rollout_compression_worker(home.clone()); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while saved_path.exists() || !saved_path.with_extension("jsonl.zst").is_file() { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }) + .await?; + let (browser_tx, mut browser_rx) = tokio::sync::mpsc::unbounded_channel(); + crate::worktree_browser::fetch( + crate::worktree_browser::Request { + id: uuid::Uuid::new_v4(), + cwd: source.clone(), + thread_id: Some(original), + }, + home.clone(), + server.request_handle(), + crate::app_event_sender::AppEventSender::new(browser_tx), + ); + let Some(AppEvent::ManagedWorktreesLoaded { + result: Ok(compressed_entries), + .. + }) = tokio::time::timeout(std::time::Duration::from_secs(10), browser_rx.recv()).await? + else { + panic!("browser must return compressed owner metadata"); + }; + assert!(compressed_entries.iter().any(|entry| matches!( + &entry.owner, + crate::worktree_browser::Owner::Resumable(thread) if thread.id == saved_owner.id + ))); + server.thread_archive(saved_owner.id).await?; + let (browser_tx, mut browser_rx) = tokio::sync::mpsc::unbounded_channel(); + crate::worktree_browser::fetch( + crate::worktree_browser::Request { + id: uuid::Uuid::new_v4(), + cwd: source.clone(), + thread_id: Some(original), + }, + home, + server.request_handle(), + crate::app_event_sender::AppEventSender::new(browser_tx), + ); + let Some(AppEvent::ManagedWorktreesLoaded { + result: Ok(entries), + .. + }) = tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 10), + browser_rx.recv(), + ) + .await? + else { + panic!("browser must return archived owner metadata"); + }; + assert!(entries.iter().any(|entry| matches!( + &entry.owner, + crate::worktree_browser::Owner::Archived(thread) if thread.id == saved_owner.id + ))); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[tokio::test] +async fn changing_directory_preserves_project_trust_permissions_history_and_hooks() -> Result<()> { + use codex_protocol::config_types::TrustLevel as T; + use serde_json::json; + use std::fs; + + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write()); + let names = ["root", "trusted", "unknown", "untrusted", "p", "failure"]; + let [current, trusted, unknown, untrusted, mismatch, failed] = + names.map(|name| codex_home.path().join(name)); + fs::create_dir_all(¤t)?; + for directory in [&trusted, &unknown, &untrusted, &mismatch, &failed] { + fs::create_dir_all(directory.join(".codex"))?; + fs::write(directory.join(".codex/config.toml"), "")?; + } + let contents = "developer_instructions = \"destination policy\"\nmodel_reasoning_effort = \"high\"\napproval_policy = \"on-request\"\n[tui]\ntheme = \"dracula\"\n[tui.keymap.global]\nopen_transcript = \"f12\""; + fs::write(trusted.join(".codex/config.toml"), contents)?; + let agents = trusted.join("AGENTS.md"); + fs::write(&agents, "Follow destination project instructions.")?; + let hooks = r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"x"}]}]}}"#; + fs::write(trusted.join(".codex/hooks.json"), hooks)?; + let contents = "default_permissions = \"dev\"\n[permissions.dev.filesystem]\n\":root\" = \"write\"\n[tui.keymap.global]\nopen_transcript = \"ctrl-l\""; + fs::write(mismatch.join(".codex/config.toml"), contents)?; + let requirements = codex_home.path().join("requirements.toml"); + let rules = "allowed_approval_policies=[\"untrusted\"]\nallowed_sandbox_modes=[\"read-only\"]"; + fs::write(&requirements, rules)?; + fs::create_dir_all(unknown.join(".git"))?; + for dir in [&trusted, &untrusted, &mismatch, &failed] { + let trust = [T::Trusted, T::Untrusted][usize::from(dir == &untrusted)]; + crate::legacy_core::config::set_project_trust_level(codex_home.path(), dir, trust) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } + app.config.cwd = current.clone().abs(); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(ThreadId::new(), current.clone())); + let (no_list, nick, role, runtime, background) = (None, None, None, None, Some("background")); + let (mut server, requests, proxy) = + start_recording_app_server(&app.config, no_list, background).await?; + let (rec, plain, req) = (recorded_params, crate::key_hint::plain, &requests); + let mut tui = crate::tui::test_support::make_test_tui()?; + let (source, message, name) = (None, None, Some("Previous project".to_string())); + app.start_fresh_session_with_summary_hint(&mut tui, &mut server, source, message, name) + .await; + let original = app.chat_widget.thread_id().expect("original thread"); + let rollout = app.chat_widget.rollout_path().expect("original rollout"); + let json = r#"{"type":"message","role":"assistant","content":[{"type":"output_text","text":"saved history"}]}"#; + let item = serde_json::from_str(json)?; + server.thread_inject_items(original, vec![item]).await?; + let tracked = server.start_thread(&app.config).await?; + let (child, capacity) = (tracked.session.thread_id, THREAD_EVENT_CHANNEL_CAPACITY); + let channel = ThreadEventChannel::new_with_session(capacity, tracked.session, tracked.turns); + app.thread_event_channels.insert(child, channel); + app.agent_navigation + .upsert(child, nick, role, /*is_closed*/ false); + let store = app.thread_event_channels[&child].store.clone(); + let config_path = codex_home.path().join("config.toml"); + let original_user_config = fs::read_to_string(&config_path).ok(); + let (local, url) = (app.environment_manager.clone(), Some("ws://[::1]".into())); + let remote = Arc::new(EnvironmentManager::create_for_tests(url, runtime).await); + let mut history = || { + std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 200))) + .collect::>() + }; + let change = |thread_id, path: &str| AppEvent::ChangeWorkingDirectory { + thread_id, + requested_cwd: path.into(), + }; + for (path, kind, expected) in [ + ("missing", "local", "Cannot access directory"), + ("../config.toml", "local", "Not a directory"), + (r"C:\bad", "workspace", "not supported for remote"), + ("~", "executor", "not supported for remote"), + ("../trusted", "stale", "requires an idle primary session"), + ("../trusted", "running", "another agent is running"), + ("../trusted", "active", "another agent is running"), + ("../trusted", "mcp", "inventory is still loading"), + ("../trusted", "approval", "approval policy override"), + ("../trusted", "profile", "permission profile override"), + ("../trusted", "reviewer", "reviewer"), + ("../p", "named", "different settings"), + ( + "../trusted", + "restored", + "Permission profile cannot be preserved", + ), + ("../p", "keymap", "open_transcript"), + ("../unknown", "local", "This directory is not trusted"), + ("../trusted", "main", "background terminals"), + ("../trusted", "child", "background terminals"), + ] { + app.config.approvals_reviewer = ApprovalsReviewer::User; + if kind == "reviewer" { + app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; + fs::write(&requirements, "allowed_approvals_reviewers = [\"user\"]")?; + } + app.agent_navigation.set_running(child, kind == "running"); + store.lock().await.active_turn_id = (kind == "active").then(|| "active".into()); + app.loader_overrides.system_requirements_path = + matches!(kind, "approval" | "profile" | "reviewer").then_some(requirements.clone()); + app.harness_overrides.permission_profile = + (kind != "named").then_some(PermissionProfile::workspace_write()); + app.runtime_approval_policy_override = (kind == "approval").then_some( + RuntimeApprovalPolicyOverride::Explicit(AskForApproval::OnRequest), + ); + let mut profile = RuntimePermissionProfileOverride::from_config(&app.config); + profile.active_permission_profile = + (kind == "named").then(|| ActivePermissionProfile::new("dev")); + if kind == "restored" { + profile.permission_profile = PermissionProfile::workspace_write_with( + &[failed.clone().abs()], + codex_protocol::permissions::NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + profile.turn_override = RuntimePermissionProfileTurnOverride::Preserve; + } + app.runtime_permission_profile_override = + matches!(kind, "profile" | "reviewer" | "named" | "restored").then_some(profile); + app.app_server_target = crate::AppServerTarget::Embedded; + if kind == "workspace" { + let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?; + app.app_server_target = crate::AppServerTarget::Remote { endpoint }; + } + app.environment_manager = [&local, &remote][usize::from(kind == "executor")].clone(); + requests.lock().expect("request recorder lock").clear(); + if kind == "mcp" { + let loading = history_cell::new_mcp_inventory_loading; + app.transcript_cells + .push(Arc::new(loading(/*animations_enabled*/ false))); + } else if kind == "child" { + app.thread_event_channels.remove(&child); + } + let thread_id = [original, ThreadId::new()][usize::from(kind == "stale")]; + app.handle_event(&mut tui, &mut server, change(thread_id, path)) + .await?; + if let Some(pending) = app.pending_working_directory_change.take() { + Box::pin(app.finish_working_directory_change(&mut tui, &mut server, pending)).await; + } + assert_eq!(app.chat_widget.thread_id(), Some(original)); + assert_eq!(app.config.cwd, current.clone().abs()); + assert!(app.runtime_working_directory_override.is_none()); + let count = requests.lock().expect("request recorder lock").len(); + let checked = usize::from(kind == "main") + 2 * usize::from(kind == "child"); + assert_eq!(count, checked, "{kind}"); + let listed = recorded_params(&requests, "thread/backgroundTerminals/list"); + let mut ids = listed.iter().zip([original, child]); + assert!(ids.all(|(p, id)| p["threadId"] == id.to_string())); + assert_eq!(fs::read_to_string(&config_path).ok(), original_user_config); + let output = history().join(""); + if kind == "mcp" { + assert_snapshot!(output, @"■ MCP inventory is still loading."); + } else if kind == "restored" { + assert_snapshot!(output, @"■ Permission profile cannot be preserved by /cd."); + } + assert!(output.contains(expected), "{path}"); + app.clear_committed_mcp_inventory_loading(); + } + let tracked = server.start_thread(&app.config).await?; + let closed = tracked.session.thread_id; + let channel = ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + tracked.session, + tracked.turns, + ); + app.thread_event_channels.insert(closed, channel); + app.agent_navigation.mark_closed(child); + for has_stale_replay_turn in [false, true] { + app.agent_navigation.upsert( + closed, /*agent_nickname*/ None, /*agent_role*/ None, + /*is_closed*/ false, + ); + let channel = app.thread_event_channels.get_mut(&closed).expect("channel"); + channel.store.lock().await.set_turns(vec![test_turn( + "stale-turn", + TurnStatus::InProgress, + Vec::new(), + )]); + if has_stale_replay_turn { + channel.mark_replay_only(); + app.agent_navigation.mark_closed(closed); + } else { + app.enqueue_thread_notification(closed, thread_closed_notification(closed)) + .await?; + } + requests.lock().expect("request recorder lock").clear(); + app.change_working_directory(&mut tui, &mut server, failed.clone().abs()) + .await; + assert_eq!( + recorded_params(&requests, "thread/backgroundTerminals/list"), + vec![json!({"threadId": original.to_string(), "cursor": null, "limit": 1})], + ); + let output = history().join(""); + insta::allow_duplicates! { + assert_snapshot!(output, @"■ Failed to change: thread/fork failed during TUI bootstrap: thread/fork failed: forced thread/name/set failure (code -32603)"); + } + } + app.agent_navigation.upsert( + child, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, + ); + app.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::AutoReview); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + for (path, expected) in [(&failed, 0), (&trusted, 2)] { + requests.lock().expect("request recorder lock").clear(); + app.change_working_directory(&mut tui, &mut server, path.clone().abs()) + .await; + assert_eq!(app.chat_widget.thread_id(), Some(original)); + assert_eq!(app.config.cwd, current.clone().abs()); + assert_eq!(rec(&requests, "thread/unsubscribe").len(), expected); + assert!(history().join("").contains("change")); + } + let removed = recorded_params(&requests, "thread/unsubscribe"); + let archived = recorded_params(&requests, "thread/archive"); + assert_eq!(removed[0]["threadId"], original.to_string()); + assert_eq!(archived[0]["threadId"], removed[1]["threadId"]); + requests.lock().expect("request recorder lock").clear(); + app.handle_event(&mut tui, &mut server, change(original, "../trusted")) + .await?; + // Dispatch only queues /cd: configuration loading happens on a fresh loop iteration. + assert_eq!(app.config.cwd, current.clone().abs()); + let pending = app + .pending_working_directory_change + .take() + .expect("valid /cd queued"); + Box::pin(app.finish_working_directory_change(&mut tui, &mut server, pending)).await; + let forked = app.chat_widget.thread_id().expect("forked thread"); + assert_ne!(forked, original); + let forked_rollout = app.chat_widget.rollout_path().expect("forked rollout"); + assert!(fs::read_to_string(&rollout)?.contains("saved history")); + let copied = fs::read_to_string(&forked_rollout)?; + let meta = codex_rollout::read_session_meta_line(&forked_rollout).await?; + let base = meta.meta.history_base; + assert!(copied.contains("saved history") || base.is_some_and(|h| h.thread_id == original)); + assert_eq!(app.config.cwd, trusted.clone().abs()); + let configured = app.primary_session_configured.as_ref().expect("session"); + let source = codex_utils_path_uri::PathUri::from_abs_path(&agents.abs()); + assert!(configured.instruction_source_paths.contains(&source)); + let (cwd, result) = (current.clone(), Err("stale skills".into())); + let skills = AppEvent::SkillsListLoaded { cwd, result }; + let (cwd, plugins) = (current.clone(), Some(vec![])); + let plugins = AppEvent::PluginMentionsLoaded { cwd, plugins }; + let diff = AppEvent::DiffResult(current.clone(), "stale diff".to_string()); + let branch = AppEvent::SyncThreadGitBranch { + thread_id: original, + branch: "stale".to_string(), + cwd: current.clone(), + }; + for event in [diff, skills, plugins, branch] { + app.handle_event(&mut tui, &mut server, event).await?; + } + let path = trusted.to_str().expect("trusted path"); + let output = history().join("").replace(path, ""); + let message = &output[output.rfind('•').expect("change")..]; + assert_snapshot!(message, @"• Working directory changed to: "); + assert!(!output.contains("stale skills")); + assert!(app.overlay.is_none()); + assert_eq!(app.keymap.app.open_transcript, vec![plain(KeyCode::F(12))]); + let anchor = app.runtime_working_directory_override.as_deref(); + assert_eq!(anchor, Some(trusted.as_path())); + let effort = app.chat_widget.current_reasoning_effort(); + assert_eq!(effort, Some(ReasoningEffortConfig::High)); + let approval = app.config.permissions.approval_policy.value(); + assert_eq!(approval, AskForApproval::OnRequest.to_core()); + let forks = recorded_params(&requests, "thread/fork"); + assert_eq!(forks.len(), 1); + let params = &forks[0]; + assert_eq!(params["threadId"], serde_json::json!(original.to_string())); + assert_eq!(params["cwd"], serde_json::json!(trusted)); + assert_eq!(params["approvalsReviewer"].as_str(), Some("auto_review")); + assert_eq!(params["developerInstructions"], "destination policy"); + assert_eq!(params["deferGoalContinuation"], serde_json::json!(true)); + assert_eq!(¶ms["runtimeWorkspaceRoots"], &json!([trusted])); + assert_eq!(rec(&requests, "hooks/list")[0]["cwds"], json!([trusted])); + for suffix in "start resume settings/update archive".split(' ') { + assert!(recorded_params(&requests, &format!("thread/{suffix}")).is_empty()); + } + assert!(rec(req, "thread/metadata/update")[0]["threadId"] == params["threadId"]); + let removed = recorded_params(&requests, "thread/unsubscribe"); + assert_eq!(removed.len(), 2); + let found = |id: ThreadId| removed.iter().any(|p| p["threadId"] == id.to_string()); + assert!([original, child].into_iter().all(found)); + let retained = server.thread_read(original, /*include_turns*/ false); + assert_eq!(retained.await?.cwd, current.abs().canonicalize()?); + assert_eq!(app.chat_widget.config_ref().cwd, trusted.clone().abs()); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("SessionStart")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.harness_overrides.bypass_hook_trust = Some(true); + requests.lock().expect("request recorder lock").clear(); + app.change_working_directory(&mut tui, &mut server, trusted.abs()) + .await; + assert!(app.config.bypass_hook_trust && !app.chat_widget.has_active_view()); + assert!(recorded_params(&requests, "hooks/list").is_empty()); + app.harness_overrides.bypass_hook_trust = None; + requests.lock().expect("request recorder lock").clear(); + app.change_working_directory(&mut tui, &mut server, untrusted.clone().abs()) + .await; + assert_eq!(app.config.active_project.trust_level, Some(T::Untrusted)); + let approval = app.config.permissions.approval_policy.value(); + assert_eq!(approval, AskForApproval::UnlessTrusted.to_core()); + assert_eq!(rec(req, "thread/fork")[0]["approvalPolicy"], "untrusted"); + let warning = "Project-local config, hooks, and exec policies are disabled"; + assert!(history().iter().any(|line| line.contains(warning))); + server.shutdown().await?; + proxy.await??; + Ok(()) +} + +#[test] +fn fresh_session_applies_requested_name() -> Result<()> { + const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; + + std::thread::Builder::new() + .name("tui-named-fresh-session".to_string()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut app_server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ Some("Add User".to_string()), + ) + .await; + + let thread_id = app + .chat_widget + .thread_id() + .expect("fresh session should have a thread id"); + assert_eq!(app.chat_widget.thread_name(), Some("Add User".to_string())); + assert!( + requests + .lock() + .expect("request recorder lock") + .iter() + .any(|request| request.method == "thread/name/set"), + "fresh session should be named through the app server" + ); + let thread = app_server + .thread_read(thread_id, /*include_turns*/ false) + .await?; + assert_eq!(thread.name.as_deref(), Some("Add User")); + + app_server.shutdown().await?; + proxy.await??; + Ok(()) + }) + })? + .join() + .expect("named fresh session test thread") +} + +#[test] +fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { + const TEST_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; + + std::thread::Builder::new() + .name("tui-session-lifecycle-requests".to_string()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = + codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); + let root_timestamp = "2026-01-01T00-00-00"; + let root_thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + root_timestamp, + "2026-01-01T00:00:00Z", + "Saved user message", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create root rollout"), + )?; + let child_thread_id = ThreadId::from_string( + &create_fake_parented_rollout_with_source( + codex_home.path(), + "2026-01-01T00-00-01", + "2026-01-01T00:00:01Z", + "Saved child message", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root_thread_id, + depth: 1, + agent_path: Some( + AgentPath::try_from("/root/worker").expect("valid agent path"), + ), + agent_nickname: Some("worker".to_string()), + agent_role: Some("worker".to_string()), + }), + root_thread_id.into(), + root_thread_id, + ) + .expect("create child rollout"), + )?; + let root_rollout_path = rollout_path( + codex_home.path(), + root_timestamp, + &root_thread_id.to_string(), + ); + let (started_tx, started_rx) = oneshot::channel(); + let (release_tx, release_rx) = oneshot::channel(); + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + Some((root_thread_id, started_tx, release_rx)), + Some("Failed Fork"), + ) + .await?; + let root = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + root_thread_id, + app.resume_model_settings(), + ) + .await?; + app.enqueue_primary_thread_session(root.session, root.turns) + .await?; + app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + child_thread_id, + app.resume_model_settings(), + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + take_backfill_counts(&requests); + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ForkCurrentSession { + name: Some("Add User Fork".to_string()), + }, + )) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert_ne!(app.chat_widget.thread_id(), Some(root_thread_id)); + let named_fork_id = app + .chat_widget + .thread_id() + .expect("named fork should have a thread id"); + assert_eq!( + app.chat_widget.thread_name(), + Some("Add User Fork".to_string()) + ); + // Forking may read the source metadata once when the response includes its parent + // id. It must not scan or backfill loaded threads for the newly created fork. + assert!(matches!(take_backfill_counts(&requests), (0, 0) | (0, 1))); + let named_fork = app_server + .thread_read(named_fork_id, /*include_turns*/ false) + .await?; + assert_eq!(named_fork.name.as_deref(), Some("Add User Fork")); + take_backfill_counts(&requests); + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ForkCurrentSession { + name: Some("Failed Fork".to_string()), + }, + )) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert_ne!(app.chat_widget.thread_id(), Some(named_fork_id)); + let name_error = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = + lines_to_single_string(&cell.display_lines(/*width*/ 80)); + rendered + .contains("Failed to name the forked session") + .then_some(rendered) + } + _ => None, + }) + .expect("fork naming error history cell"); + insta::assert_snapshot!( + name_error, + @"■ Failed to name the forked session: thread/name/set failed in TUI" + ); + assert!(matches!(take_backfill_counts(&requests), (0, 0) | (0, 1))); + + app.start_fresh_session_with_summary_hint( + &mut tui, + &mut app_server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + + assert_ne!(app.chat_widget.thread_id(), Some(root_thread_id)); + assert_eq!(take_backfill_counts(&requests), (0, 0)); + + let loaded_threads = app_server + .thread_loaded_list(ThreadLoadedListParams { + cursor: None, + limit: None, + }) + .await? + .data; + let expected_reads = loaded_threads + .iter() + .filter(|thread_id| *thread_id != &root_thread_id.to_string()) + .count(); + assert!(loaded_threads.contains(&child_thread_id.to_string())); + take_backfill_counts(&requests); + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + app.config.cwd.as_path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf()); + + let control = app + .resume_target_session( + &mut tui, + &mut app_server, + crate::resume_picker::SessionTarget { + path: Some(root_rollout_path), + thread_id: root_thread_id, + cwd: None, + history_mode: None, + }, + ) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert_eq!(app.chat_widget.thread_id(), Some(root_thread_id)); + assert_eq!(take_backfill_counts(&requests), (1, expected_reads)); + assert_eq!( + app.agent_navigation.get(&child_thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("worker".to_string()), + agent_role: Some("worker".to_string()), + agent_path: Some("/root/worker".to_string()), + is_running: false, + is_closed: false, + }) + ); + + let child_store = Arc::clone( + &app.thread_event_channels + .entry(child_thread_id) + .or_insert_with(|| ThreadEventChannel::new(/*capacity*/ 1)) + .store, + ); + let child_store_guard = child_store.lock().await; + futures::FutureExt::now_or_never(app.open_agent_picker(&mut app_server)) + .expect("opening the agent picker waited for the app server"); + drop(child_store_guard); + insta::assert_snapshot!( + render_bottom_popup(&app.chat_widget, /*width*/ 80) + .replace(&root_thread_id.to_string(), "[root]") + .replace(&child_thread_id.to_string(), "[child]"), + @r###" + Subagents + Select an agent to watch. ⌥ + ← previous, ⌥ + → next. + + › 1. • Main [default] (current) [root] + 2. • /root/worker [child] + + Press enter to confirm or esc to go back + "### + ); + assert_eq!(take_backfill_counts(&requests), (0, 0)); + tokio::time::timeout(Duration::from_secs(5), started_rx).await??; + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + futures::FutureExt::now_or_never(app.open_agent_picker(&mut app_server)) + .expect("reopening the agent picker waited for the app server"); + assert_eq!( + requests + .lock() + .expect("request recorder lock") + .iter() + .filter(|request| request.method == "thread/list") + .count(), + 1 + ); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + app.chat_widget.handle_server_request( + exec_approval_request( + root_thread_id, + "turn", + "item", + /*approval_id*/ None, + ), + /*replay_kind*/ None, + ); + app.agent_navigation.mark_stopped(child_thread_id); + release_tx.send(()).expect("release blocked thread list"); + let discovered_thread_id = ThreadId::new(); + let mut completion = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = app_event_rx.recv().await.expect("app event channel"); + if matches!(event, AppEvent::AgentPickerThreadsLoaded { .. }) { + break event; + } + } + }) + .await?; + if let AppEvent::AgentPickerThreadsLoaded { + result: Ok(threads), + .. + } = &mut completion + { + let child = threads + .iter_mut() + .find(|thread| thread.id == child_thread_id.to_string()) + .expect("root-scoped response includes the cached child"); + let mut discovered = child.clone(); + discovered.id = discovered_thread_id.to_string(); + discovered.can_accept_direct_input = None; + child.status = ThreadStatus::Active { + active_flags: Vec::new(), + }; + threads.push(discovered); + } + Box::pin(app.handle_event(&mut tui, &mut app_server, completion)).await?; + assert_eq!( + app.agent_navigation + .ordered_threads() + .last() + .map(|(thread_id, _)| *thread_id), + Some(discovered_thread_id) + ); + assert!(!app.agent_navigation.is_parent_owned(discovered_thread_id)); + assert_eq!( + app.chat_widget.selected_index_for_present_view( + super::super::agent_picker::AGENT_PICKER_VIEW_ID + ), + Some(1) + ); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("echo hello")); + assert!( + app.agent_navigation + .get(&child_thread_id) + .is_some_and(|entry| !entry.is_running) + ); + app_server.shutdown().await?; + proxy.await??; + Ok(()) + }) + })? + .join() + .expect("session lifecycle request test thread") +} + +#[path = "new_session_tests.rs"] +mod new_session_tests; +#[path = "startup_defaults_tests.rs"] +mod startup_defaults_tests; + +#[tokio::test] +async fn external_writer_escape_preserves_snapshot_and_explicit_quits() -> Result<()> { + let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; + for target in [ + AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }, + AppServerTarget::Remote { endpoint }, + ] { + let (mut app, _codex_home) = make_history_test_app().await?; + let thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "owned elsewhere")?; + let (mut server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + app.app_server_target = target; + app.active_thread_id = Some(thread_id); + app.primary_thread_id = Some(thread_id); + app.ensure_thread_channel(thread_id).mark_external_writer(); + app.chat_widget.show_external_writer_thread(); + app.chat_widget.insert_str("Retained draft"); + let (tx, mut events) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let mut tui = crate::tui::test_support::make_test_tui()?; + for key in [ + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('Q'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ] { + app.handle_key_event(&mut tui, &mut server, key).await; + assert_matches!(events.try_recv(), Ok(AppEvent::Exit(ExitMode::Immediate))); + } + app.handle_key_event( + &mut tui, + &mut server, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ) + .await; + assert!(app.chat_widget.has_active_view()); + loop { + let event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("command center refresh"); + let refreshed = matches!(&event, AppEvent::AgentsOverviewThreadsLoaded { .. }); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + if refreshed { + break; + } + } + assert_matches!( + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::SelectAgentsOverviewThread { thread_id }, + )) + .await?, + AppRunControl::Continue + ); + assert!(!app.chat_widget.has_active_view()); + assert_eq!(app.active_thread_id, Some(thread_id)); + assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + assert_eq!( + app.thread_event_channels[&thread_id].attachment(), + ThreadEventAttachment::ExternalWriter + ); + assert!(requests.lock().unwrap().iter().all(|request| matches!( + request.method.as_str(), + "thread/read" | "thread/list" | "thread/loaded/list" | "thread/turns/list" + ))); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn command_center_read_only_open_requests_and_failure_preservation() -> Result<()> { + for history_capabilities in [ + HistoryCapabilities::Current, + HistoryCapabilities::ReadAfterResumeFails, + ] { + let (mut app, _codex_home) = Box::pin(make_history_test_app()).await?; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + for cwd in [test_path_buf("/"), app.config.cwd.to_path_buf()] { + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + &cwd, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + } + let thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "Locked task")?; + let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + Box::pin(owner.resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + )) + .await?; + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &app.config, + history_capabilities, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, + }; + let current = Box::pin(server.start_thread(&app.config)).await?; + let current_id = current.session.thread_id; + app.enqueue_primary_thread_session(current.session, current.turns) + .await?; + let thread = owner + .thread_read(thread_id, /*include_turns*/ false) + .await?; + let mut view = app.agents_overview_view(vec![thread], Some(thread_id)); + view.handle_paste("Keep this draft".into()); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 96); + requests.lock().unwrap().clear(); + let mut tui = crate::tui::test_support::make_test_tui()?; + + Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, thread_id)).await?; + assert_eq!(recorded_params(&requests, "thread/resume").len(), 1); + assert!(recorded_params(&requests, "turn/start").is_empty()); + if history_capabilities == HistoryCapabilities::ReadAfterResumeFails { + let error = render_bottom_popup(&app.chat_widget, /*width*/ 96); + assert!( + error.contains("Failed to view task open elsewhere"), + "{error}" + ); + assert_eq!(app.current_displayed_thread_id(), Some(current_id)); + assert!(recorded_params(&requests, "thread/unsubscribe").is_empty()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before); + } else { + assert_eq!(app.current_displayed_thread_id(), Some(thread_id)); + assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + app.thread_event_channels[&thread_id].attachment(), + ThreadEventAttachment::ExternalWriter + ); + } + owner.shutdown().await?; + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/session_summary.rs b/codex-rs/tui/src/app/tests/session_summary.rs new file mode 100644 index 0000000000000000000000000000000000000000..63ff92a097e024571c35c67d3007fde0a98b4605 --- /dev/null +++ b/codex-rs/tui/src/app/tests/session_summary.rs @@ -0,0 +1,92 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn session_summary_skips_when_no_usage_or_resume_hint() { + assert!( + session_summary( + TokenUsage::default(), + /*thread_id*/ None, + /*thread_name*/ None, + /*rollout_path*/ None, + ) + .is_none() + ); +} + +#[tokio::test] +async fn session_summary_skips_resume_hint_until_rollout_exists() { + let usage = TokenUsage::default(); + let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let temp_dir = tempdir().expect("temp dir"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + + assert!( + session_summary( + usage, + Some(conversation), + /*thread_name*/ None, + Some(&rollout_path), + ) + .is_none() + ); +} + +#[tokio::test] +async fn session_summary_includes_resume_hint_for_persisted_rollout() { + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let temp_dir = tempdir().expect("temp dir"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + std::fs::write(&rollout_path, "{}\n").expect("write rollout"); + + let summary = session_summary( + usage, + Some(conversation), + /*thread_name*/ None, + Some(&rollout_path), + ) + .expect("summary"); + assert_eq!( + summary.usage_line, + Some("Token usage: total=12 input=10 output=2".to_string()) + ); + assert_eq!( + summary.resume_hint, + Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) + ); +} + +#[tokio::test] +async fn session_summary_names_picker_item_when_thread_has_name() { + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); + let temp_dir = tempdir().expect("temp dir"); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + std::fs::write(&rollout_path, "{}\n").expect("write rollout"); + + let summary = session_summary( + usage, + Some(conversation), + Some("my-session".to_string()), + Some(&rollout_path), + ) + .expect("summary"); + assert_eq!( + summary.resume_hint, + Some( + "codex resume, then select my-session (123e4567-e89b-12d3-a456-426614174000)" + .to_string() + ) + ); +} diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__reconnect_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__reconnect_failed.snap new file mode 100644 index 0000000000000000000000000000000000000000..86c54bfc86a83a7723ac2d716783f556eb3a6b6a --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__reconnect_failed.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/active_reconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- +• Reconnect failed — check the endpoint, then relaunch (0s) + + +› Ask Codex to do anything + + ctrl+c quit diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__restored_conversation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__restored_conversation.snap new file mode 100644 index 0000000000000000000000000000000000000000..e6a6142008edc6c2eaa937bf43acfebb1a01f56e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__restored_conversation.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/active_reconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- +• Queued follow-up inputs + ↳ old queued input + ⌥ + ↑ edit last queued message + +› kept draft + + gpt-test default Plan mode (shift+tab to cycle) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__unavailable_conversation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__unavailable_conversation.snap new file mode 100644 index 0000000000000000000000000000000000000000..6ac1df352783ccc22ff0a9058b6369c5c9a21be8 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__unavailable_conversation.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/app/tests/active_reconnect_tests.rs +expression: "format!(\"{history}\\n{}\", render_bottom_popup(&app.chat_widget, 80))" +--- +• This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue. +• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused. +› unavailable draft + + gpt-test default · /tmp/project diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap new file mode 100644 index 0000000000000000000000000000000000000000..8fd3c202a11934dae99e482b7d6e735790a3db4d --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_after_fallback.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/tests/backend_banner_fallback_tests.rs +expression: normalize_snapshot_paths(rendered) +--- + Selected model usage exhausted + Add credits to use this model again. +› 1. View usage + + Press a number to choose · esc to dismiss · type to continue + +› Ask Codex to do anything + + gpt-5.6-terra medium · /tmp/project diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_notice.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_notice.snap new file mode 100644 index 0000000000000000000000000000000000000000..982f67894c75349e000e6ac2fc1b1babba5f1377 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_notice.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests/backend_banner_fallback_tests.rs +expression: "notices[0]" +--- +• Automatically switched to gpt-5.6-terra medium due to usage limits. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_unavailable.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_unavailable.snap new file mode 100644 index 0000000000000000000000000000000000000000..bb2bcc2ec111855c9f677e6827a7a1f8eb448212 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__backend_banner_fallback_unavailable.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/backend_banner_fallback_tests.rs +expression: normalize_snapshot_paths(rendered) +--- +› Ask Codex to do anything + + gpt-5.5 default · /tmp/project diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap new file mode 100644 index 0000000000000000000000000000000000000000..bae8c11321bef8da35054c7614bfca38d4da96f4 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__backend_banner_fallback_tests__luna_reserve_settings_unavailable.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/backend_banner_fallback_tests.rs +expression: normalize_snapshot_paths(rendered) +--- + Usage limit reached + Your included usage is exhausted. Choose an option below to continue. + +› 1. Add credits + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__daemon_disconnect_exit.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__daemon_disconnect_exit.snap new file mode 100644 index 0000000000000000000000000000000000000000..720042aed7836ef3398e057b9b937e7e39002679 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__daemon_disconnect_exit.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +expression: output +--- +Disconnected from this task. Any running work continues. +Reconnect: codex resume THREAD_ID +Stop the current turn: run codex agents, select this task, and press f10. +Token usage so far: total=12 input=10 output=2 diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__interrupted_disconnect_exit.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__interrupted_disconnect_exit.snap new file mode 100644 index 0000000000000000000000000000000000000000..7ee70098414c9ed3cc0f9c4d9b7c0309e907be2f --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__interrupted_disconnect_exit.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +expression: output +--- +Disconnected from this task. The current turn was stopped. +Reconnect: codex resume THREAD_ID diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap new file mode 100644 index 0000000000000000000000000000000000000000..b3ae684ab6a86589a75b47739976b5377b710b67 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_exit_tests__remote_disconnect_exit.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/background_exit_tests.rs +expression: "lines.join(\"\\n\")" +--- +Disconnected from this task. Work may still be running. +Reconnect: codex --remote wss://example.com:443/ resume 123e4567-e89b-12d3-a456-426614174000 +Stop the current turn: run codex --remote wss://example.com:443/ agents, select this task, and press x. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap new file mode 100644 index 0000000000000000000000000000000000000000..980c304f2ddf597f8a181cb82bd93ae9ae511536 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_pending_server_permissions.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/background_task_defaults_tests.rs +expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)" +--- + Unable to complete action + Wait for permissions to update before starting a session. + +› 1. Return to command center + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap new file mode 100644 index 0000000000000000000000000000000000000000..c8d6f282a4a05a8d9a6f0c205143296f8db880c6 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_retained_worktree_error.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/background_task_defaults_tests.rs +expression: "rendered.replace(&unused.root.display().to_string(), \"\")" +--- + Unable to complete action + Failed to start session: thread/start failed during TUI bootstrap: thread/start failed: replacement unavailable (code -32603) A checkout was retained at ; remove it with `git worktree remove ` from the source repository if it is no longer needed. + +› 1. Return to command center + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap new file mode 100644 index 0000000000000000000000000000000000000000..6041f1da37b9a422a5901159db1041b03395bdb5 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__background_task_defaults_tests__command_center_session_start_error.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/background_task_defaults_tests.rs +expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80,)" +--- + Unable to complete action + Failed to start session: thread/start failed during TUI bootstrap: thread/ + start failed: replacement unavailable (code -32603) + +› 1. Return to command center + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__buffered_replay__misalignment_replayed_failed_tool.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__buffered_replay__misalignment_replayed_failed_tool.snap new file mode 100644 index 0000000000000000000000000000000000000000..205a72450120f7a21cef8484ba3a1e3e2709d05e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__buffered_replay__misalignment_replayed_failed_tool.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/buffered_replay.rs +expression: rendered +--- +• Called sample.inspect({}) + └ Error: interrupted diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__buffered_replay__misalignment_replayed_input.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__buffered_replay__misalignment_replayed_input.snap new file mode 100644 index 0000000000000000000000000000000000000000..2b4062523e5572477cd998864f2158f5bc0a519e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__buffered_replay__misalignment_replayed_input.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/buffered_replay.rs +expression: "normalize_snapshot_paths(render_bottom_popup(&app.chat_widget, 80))" +--- +• Queued follow-up inputs + ↳ keep queued + ⌥ + ↑ edit last queued message + +› keep draft + + gpt-5.6-sol default · /tmp/project diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap new file mode 100644 index 0000000000000000000000000000000000000000..8f1849f991e38fe8308b46e4faa1a38544641a94 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/disconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + Reconnecting — agent list is stale + ──────────────────────────────────────────────────────────────────────────────────────────────── + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + ctrl+c quit · actions paused until the list is refreshed diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_draft.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_draft.snap new file mode 100644 index 0000000000000000000000000000000000000000..4722388cf841af06913861af5387f4e9752fa42a --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_draft.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/tests/disconnect_tests.rs +expression: "format!(\"{}\\n{}\",\nlines_to_single_string(&app.transcript_cells.last().unwrap().display_lines(80)),\nrender_bottom_popup(&app.chat_widget, 80))" +--- +■ Connection lost. Attempting to reconnect… +• Reconnecting to app-server… (0s) + +• Queued follow-up inputs + ↳ queued before disconnect + ⌥ + ↑ edit last queued message + +› keep this draft! + + ctrl+c quit diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_expanded_paste.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_expanded_paste.snap new file mode 100644 index 0000000000000000000000000000000000000000..9dcda8874113c206549169e00293262cae143440 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_expanded_paste.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/app/tests/disconnect_tests.rs +expression: draft +--- +queued before disconnectcopied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__luna_reserve_recovery_tests__luna_reserve_recovery_notice.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__luna_reserve_recovery_tests__luna_reserve_recovery_notice.snap new file mode 100644 index 0000000000000000000000000000000000000000..363704bd3e6dbec9b9ccc6b8c49ed96e9cfeb7d8 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__luna_reserve_recovery_tests__luna_reserve_recovery_notice.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests/luna_reserve_recovery_tests.rs +expression: notice +--- +• Automatically switched back to gpt-5.5 high because ordinary usage is available again. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__answer_math_narrow.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__answer_math_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..7cf1280f79231ef5d4cd0f6f404def2ca2df1dd2 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__answer_math_narrow.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/math_interruption_tests.rs +expression: rendered +--- +• Intro. + + $$ + \frac{a+b+c+d+e+f} + {g+h} + +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__plan_error_math_narrow.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__plan_error_math_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..844b078cf625534f910cd33a272583c00273e499 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__plan_error_math_narrow.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/tests/math_interruption_tests.rs +expression: rendered +--- +• Proposed Plan + + + Intro. + + $$ + \frac{a+b+c+d+e+ + f}{g+h} + + +■ Server error diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__plan_math_narrow.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__plan_math_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..7ca58045f129f9ee93e497d23cba9f022de96993 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__math_interruption_tests__plan_math_narrow.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/tests/math_interruption_tests.rs +expression: rendered +--- +• Proposed Plan + + + Intro. + + $$ + \frac{a+b+c+d+e+ + f}{g+h} + + +■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap new file mode 100644 index 0000000000000000000000000000000000000000..54a988af48189fa3db6860fbaf9ef878b3929a20 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_continue_failed.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/tests/misalignment_policy_tests.rs +expression: "format!(\"{errors}\\n{}\", render_bottom_popup(&app.chat_widget, 80))" +--- +■ Couldn’t continue this chat. Review its latest status before trying again. + Chat paused as a precaution + We couldn’t confirm the agent was interpreting your instructions correctly. + Review what we detected before deciding to continue. + +› 1. Review findings + 2. New chat + 3. Resume another chat diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap new file mode 100644 index 0000000000000000000000000000000000000000..853ddd2089dc88a61ebe02723437a3d04337f106 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__misalignment_policy__misalignment_findings_38.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/app/tests/misalignment_policy_tests.rs +assertion_line: 75 +expression: terminal.backend() +--- +"/ What we detected/ / / / / / / / / / " +"Continuation request (quoted) " +""Continue **only** within the " +"requested scope.\nDo not edit files." " +" " +"The proposed action exceeded the " +"request. " +" " +"The proposed action exceeded the " +"request. " +" " +"The proposed action exceeded the " +"───────────────────────────────── 0% ─" +" ↑/↓ to scroll pgup/pgdn to page h" +" q close " +" " diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_catalog__model_migration_prompt_shows_for_hidden_model.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_catalog__model_migration_prompt_shows_for_hidden_model.snap new file mode 100644 index 0000000000000000000000000000000000000000..06d2e887888acba35abdafee8e75629c931f1473 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_catalog__model_migration_prompt_shows_for_hidden_model.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/model_catalog.rs +expression: model_migration_copy_to_plain_text(©) +--- +Codex just got an upgrade. Introducing GPT-5.5. + +We recommend switching from gpt-5.2 to gpt-5.5. + +Frontier model for complex coding, research, and real-world work. + +You can continue using gpt-5.2 if you prefer. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__overridden_model_defaults.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__overridden_model_defaults.snap new file mode 100644 index 0000000000000000000000000000000000000000..0bf2a99ec508c89a2b7499b7c2c45f0621a275dc --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__overridden_model_defaults.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/tests/model_defaults_tests.rs +expression: messages +--- +⚠ Saved default model and reasoning effort, but a higher-priority configuration + layer overrides the saved value. +• Model changed to gpt-5.5 high +⚠ Saved Plan mode reasoning effort, but a higher-priority configuration layer + overrides the saved value. +⚠ Saved default service tier, but a higher-priority configuration layer + overrides the saved value. +• Service tier set to priority +⚠ Saved default model and reasoning effort, but a higher-priority configuration + layer overrides the saved value. +• Model changed to gpt-5.5 ultra for this conversation diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__session_only_model_confirmation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__session_only_model_confirmation.snap new file mode 100644 index 0000000000000000000000000000000000000000..297c047e9c365655f943179ac067011170c8c82e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__model_defaults__session_only_model_confirmation.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/app/tests/model_defaults_tests.rs +expression: next_history_message(&mut events) +--- +• Model changed to gpt-5.6-terra high for this session only diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap new file mode 100644 index 0000000000000000000000000000000000000000..5459120cb9af74156edce860985aae1b6bb418f4 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/navigation_reconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + Reconnect failed — agent list is stale; relaunch to retry + ──────────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● Selected task Working │ + ○ Vanished task Ready │ Selected task + │ ● Working + │ + │ Project + │ /tmp/project + │ Model: Unknown + │ + │ Prompt + │ Selected task + │ + │ + │ + │ + │ + │ + │ + │ + Rename › Keep this task draft! + esc cancel · actions paused until reconnected diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap new file mode 100644 index 0000000000000000000000000000000000000000..5f7c1bef638c381e84b9a2468b3d9610d8d81d1c --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/navigation_reconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + Reconnecting — agent list is stale + ──────────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● Selected task Working │ + ○ Vanished task Ready │ Selected task + │ ● Working + │ + │ Project + │ /tmp/project + │ Model: Unknown + │ + │ Prompt + │ Selected task + │ + │ + │ + │ + │ + │ + │ + │ + Rename › Keep this task draft! + esc cancel · actions paused until reconnected diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap new file mode 100644 index 0000000000000000000000000000000000000000..e45ce60aba348a672bd0bad7e553a2498a90d409 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/navigation_reconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + 0 need input 1 working 1 ready + ──────────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● New task Working │ + ○ Selected task Ready │ New task + │ ● Working + │ + │ Project + │ /tmp/project + │ Model: Unknown + │ + │ Prompt + │ New task + │ + │ + │ + │ + │ + │ + │ + │ + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop + h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap new file mode 100644 index 0000000000000000000000000000000000000000..88842c333e895ed40568f90f8047cc0bd667f526 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/tests/navigation_reconnect_tests.rs +expression: "format!(\"{content}\\n{}\", render_bottom_popup(&app.chat_widget, 80))" +--- +Cached previous conversation + +• This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue. +• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused. +• The rename target disappeared. Unsubmitted title: Keep this task draft! +› latest draft + + gpt-test default · /tmp/project diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__pagination_completion_tests__older_pagination_completion_footers_follow_answers_without_overlap_duplicates.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__pagination_completion_tests__older_pagination_completion_footers_follow_answers_without_overlap_duplicates.snap new file mode 100644 index 0000000000000000000000000000000000000000..7263cb06b5d8a91f85d0f724b2d860c44b8775b0 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__pagination_completion_tests__older_pagination_completion_footers_follow_answers_without_overlap_duplicates.snap @@ -0,0 +1,26 @@ +--- +source: tui/src/app/tests/pagination_completion_tests.rs +expression: "snapshots.join(\"\\n\\n--- next overlapping page ---\\n\\n\")" +--- +Earlier messages are available — press ctrl + t to view the full transcript +• Middle answer + + Worked for 2m 5s · done Sep 6, 2000 at 2:33 PM + +--- next overlapping page --- + + +› Oldest prompt + + +• Oldest answer + + Worked for 2m 5s · done Sep 6, 2000 at 2:32 PM + + +› Middle prompt + + +• Middle answer + + Worked for 2m 5s · done Sep 6, 2000 at 2:33 PM diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__permission_shortcuts_tests__permission_shortcut_server_error.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__permission_shortcuts_tests__permission_shortcut_server_error.snap new file mode 100644 index 0000000000000000000000000000000000000000..9b439c6614c164326fb74639f88147da1f7c70f1 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__permission_shortcuts_tests__permission_shortcut_server_error.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/permission_shortcuts_tests.rs +assertion_line: 63 +expression: "lines_to_single_string(&cell.display_lines(120)).replace(&missing_thread.to_string(),\n\"\")" +--- +■ Failed to update permissions: thread/settings/update failed in TUI: thread/settings/update failed: thread not found: (code -32600) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__permission_shortcuts_tests__permission_shortcut_unsupported.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__permission_shortcuts_tests__permission_shortcut_unsupported.snap new file mode 100644 index 0000000000000000000000000000000000000000..310cad47857456f5dda2781f339877491e0d8d62 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__permission_shortcuts_tests__permission_shortcut_unsupported.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/permission_shortcuts_tests.rs +assertion_line: 33 +expression: lines_to_single_string(&cell.display_lines(120)) +--- +■ Failed to update permissions: this app server does not support confirmed permission changes; use /permissions diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__overridden_voice_save.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__overridden_voice_save.snap new file mode 100644 index 0000000000000000000000000000000000000000..b621fee738fbfd1506114a37310e51c808444e73 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__overridden_voice_save.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/realtime_requests.rs +assertion_line: 1679 +expression: message +--- +■ Voice preference was saved but not applied: Overridden by session flags diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__rejected_realtime_speech.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__rejected_realtime_speech.snap new file mode 100644 index 0000000000000000000000000000000000000000..ad1abc1dccbf2d8934280718d123d6fc7eb0cce0 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__rejected_realtime_speech.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/realtime_requests.rs +assertion_line: 1083 +expression: "rendered.join(\"\\n\").replace(&thread_id.to_string(), \"\")" +--- +• An answer to keep. +■ Voice conversation failed: thread/realtime/appendSpeech failed in TUI: thread/realtime/appendSpeech failed: thread not found: (code -32600) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap new file mode 100644 index 0000000000000000000000000000000000000000..74eba06dee1dc2ef9ecdb367c786e00592297273 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/app/tests/realtime_requests.rs +assertion_line: 1638 +expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 80)" +--- + Select voice + Applies to your next voice conversation. + + 1. juniper + 2. maple + 3. spruce + 4. ember + 5. vale + 6. breeze + 7. arbor +› 8. sol (current) + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap new file mode 100644 index 0000000000000000000000000000000000000000..ca2b2cb7a5fe28d4597f7be7858afa36e5f9e9bf --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__remote_voice_picker_server_default.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/app/tests/realtime_requests.rs +assertion_line: 1721 +expression: popup +--- + Select voice + Applies to your next voice conversation. + +› 1. maple (current) + 2. cove + 3. juniper + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_buffered_replay_reconciles_captions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_buffered_replay_reconciles_captions.snap new file mode 100644 index 0000000000000000000000000000000000000000..0b2502336356d79712929670495c9371c73f2a2e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_buffered_replay_reconciles_captions.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/app/tests/realtime_requests.rs +assertion_line: 645 +expression: "normalize_voice_snapshot_directory(&rendered, &app.config.cwd)" +--- +╭────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: gpt-test /model to change │ +│ directory: /tmp/project │ +╰────────────────────────────────────────╯ + + To get started, describe a task or try one of these commands: + + /init - create an AGENTS.md file with instructions for Codex + /status - show current session configuration + /permissions - choose what Codex is allowed to do + /model - choose what model and reasoning effort to use + /review - review any changes and find issues + +› buffered question + +• buffered answer diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_completed_after_thread_switch.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_completed_after_thread_switch.snap new file mode 100644 index 0000000000000000000000000000000000000000..8893207f3b7d2388cfead7e549a81164d12b2e9b --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_completed_after_thread_switch.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/app/tests/realtime_requests.rs +assertion_line: 1023 +expression: "normalize_voice_snapshot_directory(&rendered, &app.config.cwd)" +--- +╭────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: gpt-test /model to change │ +│ directory: /tmp/project │ +╰────────────────────────────────────────╯ + + To get started, describe a task or try one of these commands: + + /init - create an AGENTS.md file with instructions for Codex + /status - show current session configuration + /permissions - choose what Codex is allowed to do + /model - choose what model and reasoning effort to use + /review - review any changes and find issues +• Earlier answer +• spoken complete + +› Later typed question diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap new file mode 100644 index 0000000000000000000000000000000000000000..44a8b74e8ffb4c5f5ace0fc5f9381c79f6efe0b7 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/app/tests/realtime_requests.rs +assertion_line: 302 +expression: "normalize_voice_snapshot_directory(&rendered, &app.config.cwd)" +--- +╭────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: gpt-test /model to change │ +│ directory: /tmp/project │ +╰────────────────────────────────────────╯ + + To get started, describe a task or try one of these commands: + + /init - create an AGENTS.md file with instructions for Codex + /status - show current session configuration + /permissions - choose what Codex is allowed to do + /model - choose what model and reasoning effort to use + /review - review any changes and find issues + +› spoken partial completed diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_replay_reconciles_matching_captions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_replay_reconciles_matching_captions.snap new file mode 100644 index 0000000000000000000000000000000000000000..76e81da3ea2ed5cd822c7db42b70acf37f80ca30 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_replay_reconciles_matching_captions.snap @@ -0,0 +1,33 @@ +--- +source: tui/src/app/tests/realtime_requests.rs +assertion_line: 561 +expression: "normalize_voice_snapshot_directory(&rendered, &app.config.cwd)" +--- +╭────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: gpt-test /model to change │ +│ directory: /tmp/project │ +╰────────────────────────────────────────╯ + + To get started, describe a task or try one of these commands: + + /init - create an AGENTS.md file with instructions for Codex + /status - show current session configuration + /permissions - choose what Codex is allowed to do + /model - choose what model and reasoning effort to use + /review - review any changes and find issues + +› show & tests + +• spoken answer + +› typed words + + +› show & tests + + +› typed words + +• different caption diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_start__rejected_voice_start_no_active_thread.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_start__rejected_voice_start_no_active_thread.snap new file mode 100644 index 0000000000000000000000000000000000000000..dcab8f663df4f8a841da834a95ed914ccdd45c64 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_start__rejected_voice_start_no_active_thread.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/realtime_start.rs +assertion_line: 59 +expression: rendered +--- +■ No active thread is available. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_start__rejected_voice_start_replay_only.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_start__rejected_voice_start_replay_only.snap new file mode 100644 index 0000000000000000000000000000000000000000..fb035f03362507368330a51b2d93a172c474020e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_start__rejected_voice_start_replay_only.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/realtime_start.rs +assertion_line: 57 +expression: rendered +--- +■ This conversation is read-only or unavailable; no operation was sent. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__recap_generation__auto_recap_disabled_transcript.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__recap_generation__auto_recap_disabled_transcript.snap new file mode 100644 index 0000000000000000000000000000000000000000..ee2de56e51f5acb0a45695f72f906f6766c9cf73 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__recap_generation__auto_recap_disabled_transcript.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/recap_generation_tests.rs +expression: lines_to_single_string(&rendered) +--- + +› Finish the recap implementation diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_retry_committed_steer_history.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_retry_committed_steer_history.snap new file mode 100644 index 0000000000000000000000000000000000000000..0adc4a3857f02b3adf26a5c3da8ba71b3d533921 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__safety_buffering__safety_retry_committed_steer_history.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/safety_buffering.rs +expression: rendered_retry +--- +› Handle the safety-buffered request + Keep the accepted steer +⚠ Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.• done done [completion time] diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap new file mode 100644 index 0000000000000000000000000000000000000000..7d1c018a64bc0dcfe7f5d5c4e0de4be06f7ab093 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__agents_command_center_after_archive.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/session_lifecycle_requests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + 0 need input 0 working 0 ready + ──────────────────────────────────────────────────────────────────────────────────────────────── + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + ↑↓ navigate o resume enter open n new w new worktree f search g group: project r rename x stop + h hide a archive del delete ctrl+c quit diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__new_session_tests__new_session_preserves_vim_line_yank.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__new_session_tests__new_session_preserves_vim_line_yank.snap new file mode 100644 index 0000000000000000000000000000000000000000..e543c9722f328822533df1201159627b4a776613 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__new_session_tests__new_session_preserves_vim_line_yank.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/new_session_tests.rs +assertion_line: 55 +expression: composer_lines +--- +› new line + saved line diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mcp_startup_details.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mcp_startup_details.snap new file mode 100644 index 0000000000000000000000000000000000000000..88ee611162a4096ea5b0a4e1ac4022a1998adedf --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mcp_startup_details.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/app/tests/startup_warnings_tests.rs +expression: lines_to_single_string(&warnings.transcript_lines(40)) +--- +⚠ MCP alpha: connection unavailable +⚠ MCP beta: connection unavailable +⚠ MCP gamma: connection unavailable +⚠ MCP startup incomplete (failed: alpha, + beta, gamma) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mcp_startup_summary.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mcp_startup_summary.snap new file mode 100644 index 0000000000000000000000000000000000000000..6b5d082fa8b9d745626c36b753681a4ed53bd58b --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mcp_startup_summary.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/startup_warnings_tests.rs +expression: warnings.display_lines(100) +--- +[ + Line::from(Span::from("⚠ 3 MCP startup issues (2 need sign-in) · ctrl + t for details").yellow().dim()), +] diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mixed_startup_summary.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mixed_startup_summary.snap new file mode 100644 index 0000000000000000000000000000000000000000..9576e9ffae8785a3b8c2de3dcabafdaa3c361db9 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__startup_warnings_tests__mixed_startup_summary.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/app/tests/startup_warnings_tests.rs +expression: "app.transcript_cells[1].display_lines(100)" +--- +[ + Line::from(Span::from("⚠ 4 startup issues (3 MCP; 2 need sign-in) · ctrl + t for details").yellow().dim()), +] diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__analytics_menu_reopen_preserves_navigation_and_explicit_view_selects_summary.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__analytics_menu_reopen_preserves_navigation_and_explicit_view_selects_summary.snap new file mode 100644 index 0000000000000000000000000000000000000000..3980dcd75bd0c89015098a4f7a95819802a3b61a --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__analytics_menu_reopen_preserves_navigation_and_explicit_view_selects_summary.snap @@ -0,0 +1,78 @@ +--- +source: tui/src/app/tests/transcript_composer.rs +expression: snapshot +--- + Analytics · Live account + [7d] 30d · [date range] + + 1 Summary 2 Total usage history 3 Messages [4 Plugins called] 5 Skills + used 6 Top chats + + ▎ Plugins called + ──────────────────────────────────────────────────────────────────────────── + No data reported for this range. + + + + + + + + + + + + + + tab/1–6 section · z dashboard · ←/→ day + r 7/30d · R refresh · esc back · q close + + Analytics · Live account + [7d] 30d · [date range] + + 1 Summary 2 Total usage history 3 Messages [4 Plugins called] 5 Skills + used 6 Top chats + + ▎ Plugins called + ──────────────────────────────────────────────────────────────────────────── + No data reported for this range. + + + + + + + + + + + + + + tab/1–6 section · z dashboard · ←/→ day + r 7/30d · R refresh · esc back · q close + + Analytics · Live account + + + [1 Summary] 2 Total usage history 3 Messages 4 Plugins called 5 Skills + used 6 Top chats + + ▎ Summary + ──────────────────────────────────────────────────────────────────────────── + Plus + + 12.3K — — + Lifetime tokens Peak tokens Longest chat + + — — + Current streak Longest streak + + Token activity Weekly + Last 12 months + + Token activity history unavailable + + Activity insights Most used plugins and skills + tab/1–6 section · z dashboard · ↑/↓ scroll + g view · R refresh · pgup/pgdn scroll · esc back · q close diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_close_restores_inline_draft.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_close_restores_inline_draft.snap new file mode 100644 index 0000000000000000000000000000000000000000..9888ce81a5f81ef0d815e347132adc166fd5e54b --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_close_restores_inline_draft.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/app/tests/transcript_composer.rs +expression: "buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal))" +--- + + +› EDGE-DRAFT-MUST-SURVIVE + + gpt-5.6-sol default · /tmp/project diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap new file mode 100644 index 0000000000000000000000000000000000000000..857ca84bd82882237f7c441004da44dc4a026208 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/tests/transcript_composer.rs +expression: text +--- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / + +› first + + + +› second + +───────────────────────────────────────────────────────────────────────────────────────────── 100% ─ + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q close esc to edit prev diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_foreground_history.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_foreground_history.snap new file mode 100644 index 0000000000000000000000000000000000000000..2957f3e2b1df4936523202281f4c63892054c570 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_foreground_history.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/tests/user_verification_routes.rs +assertion_line: 194 +expression: "app.render_transcript_lines_for_reflow(80).lines.iter().map(rendered_line_text).collect::>().join(\"\\n\")" +--- +• The foreground answer continues. diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..4087c6a56181a51075439438ec523c6909665131 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__inactive_thread_user_verification_prompt.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/app/tests/user_verification_routes.rs +assertion_line: 67 +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Approve deployment? + + Thread: Verifier [worker] + + Server: deployments + + Verify the production deployment. + +› 1. Verify and approve (y) + 2. Cancel this request (c) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__side_toggle_surfaces_pending_user_verification.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__side_toggle_surfaces_pending_user_verification.snap new file mode 100644 index 0000000000000000000000000000000000000000..d0e5c5a67310005fea31652992cc2222597ff614 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__user_verification_routes__side_toggle_surfaces_pending_user_verification.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/tests/user_verification_routes.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Approve deployment? + + Thread: Verifier [worker] + + Server: deployments + + Verify the production deployment. + +› 1. Verify and approve (y) + 2. Cancel this request (c) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs new file mode 100644 index 0000000000000000000000000000000000000000..cd875484d724147d370ca1ea52552bfdc80ef767 --- /dev/null +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -0,0 +1,1707 @@ +use super::session_lifecycle_requests::recorded_params; +use super::session_lifecycle_requests::start_recording_remote_app_server; +use super::*; +use crate::ThreadParamsMode; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::BottomPane; +use crate::bottom_pane::BottomPaneParams; +use crate::tui::FrameRequester; +use app_test_support::create_fake_parented_rollout_with_source; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_utils_approval_presets::builtin_approval_presets; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::unbounded_channel; + +#[tokio::test] +async fn windows_sandbox_setup_uses_local_app_server_connection() { + let mut app = make_test_app().await; + app.chat_widget.windows_sandbox_local_server = true; + assert!(app.windows_sandbox_setup_is_local()); + + let endpoint = crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:4500".to_string(), + auth_token: None, + }; + app.app_server_target = crate::AppServerTarget::LocalDaemon { + endpoint: endpoint.clone(), + }; + assert!(app.windows_sandbox_setup_is_local()); + + app.app_server_target = crate::AppServerTarget::Remote { endpoint }; + assert!(!app.windows_sandbox_setup_is_local()); +} + +#[tokio::test] +async fn windows_sandbox_setup_uses_observed_thread_host() { + let mut app = make_test_app().await; + app.chat_widget.windows_sandbox_local_server = true; + app.chat_widget.windows_sandbox_elevated_setup_complete = true; + for (ids, expected) in [ + (None, WindowsSandboxHost::Unknown), + (Some(vec![]), WindowsSandboxHost::Unknown), + ( + Some(vec![codex_exec_server::LOCAL_ENVIRONMENT_ID]), + WindowsSandboxHost::Local, + ), + (Some(vec!["remote"]), WindowsSandboxHost::Remote), + ( + Some(vec!["remote", codex_exec_server::LOCAL_ENVIRONMENT_ID]), + WindowsSandboxHost::Mixed, + ), + ] { + let environments = ids.map(|ids| { + ids.into_iter() + .map(|id| codex_app_server_protocol::ThreadEnvironment { + environment_id: id.to_string(), + cwd: codex_utils_absolute_path::AbsolutePathBuf::try_from(std::env::temp_dir()) + .unwrap() + .into(), + runtime_workspace_roots: Vec::new(), + }) + .collect::>() + }); + let mut session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()); + session.windows_sandbox_host = + crate::windows_sandbox::host_from_environments(environments.as_deref()); + app.chat_widget.handle_thread_session(session); + assert!(app.chat_widget.windows_sandbox_elevated_setup_complete); + assert_eq!(app.windows_sandbox_host(), expected); + assert_eq!( + app.windows_sandbox_setup_is_local(), + expected == WindowsSandboxHost::Local + ); + } + app.chat_widget.windows_sandbox_host = WindowsSandboxHost::Local; + app.chat_widget.windows_sandbox_local_server = false; + assert!(!app.windows_sandbox_setup_is_local()); +} + +#[tokio::test] +async fn uncertain_windows_sandbox_setup_keeps_intent_and_input_locked() { + use crate::app_event::WindowsSandboxEnableMode; + use codex_app_server_protocol::WindowsSandboxSetupStartResponse; + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + + for response in [ + Ok(Err(TypedRequestError::Transport { + method: "windowsSandbox/setupStart".to_string(), + source: std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection closed"), + })), + tokio::time::timeout( + Duration::ZERO, + std::future::pending::< + std::result::Result, + >(), + ) + .await, + ] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + while events.try_recv().is_ok() {} + app.windows_sandbox.pending_setup = + Some((WindowsSandboxEnableMode::Elevated, preset.clone(), None)); + app.windows_sandbox.setup_started_at = Some(Instant::now()); + app.chat_widget.show_windows_sandbox_setup_status(); + let transport_error = matches!(&response, Ok(Err(TypedRequestError::Transport { .. }))); + + app.finish_windows_sandbox_setup_start(response); + + assert!(app.windows_sandbox.pending_setup.is_some()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!(app.chat_widget.composer_text_with_pending().is_empty()); + let cell = match events.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected setup error message, got {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 160)); + if transport_error { + insta::assert_snapshot!(rendered, @"■ Windows sandbox setup response was lost. Waiting for completion or reconnection."); + } else { + insta::assert_snapshot!(rendered, @"■ Windows sandbox setup request timed out. Waiting for completion; restart Codex if it does not finish."); + } + } +} + +#[tokio::test] +async fn windows_sandbox_setup_completion_requires_matching_pending_mode() -> Result<()> { + use crate::app_event::WindowsSandboxEnableMode; + use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; + use codex_app_server_protocol::WindowsSandboxSetupMode; + + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.chat_widget.windows_sandbox_local_server = true; + while events.try_recv().is_ok() {} + let app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + app.windows_sandbox.pending_setup = Some((WindowsSandboxEnableMode::Elevated, preset, None)); + app.windows_sandbox.setup_started_at = Some(Instant::now()); + + for mode in [ + WindowsSandboxSetupMode::Unelevated, + WindowsSandboxSetupMode::Elevated, + ] { + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::WindowsSandboxSetupCompleted( + WindowsSandboxSetupCompletedNotification { + mode, + success: true, + error: None, + }, + ), + )), + ) + .await; + assert_eq!( + app.windows_sandbox.pending_setup.is_some(), + mode == WindowsSandboxSetupMode::Unelevated + ); + } + assert!(!app.chat_widget.windows_sandbox_elevated_setup_complete); + assert!(app.windows_sandbox_blocks_thread_switch()); + assert!(matches!( + events.try_recv(), + Ok(AppEvent::EnableWindowsSandboxForAgentMode { + mode: WindowsSandboxEnableMode::Elevated, + .. + }) + )); + assert!(events.try_recv().is_err()); + + app_server.shutdown().await?; + Ok(()) +} + +fn startup_bottom_pane() -> (BottomPane, UnboundedReceiver) { + let (app_event_tx, app_event_rx) = unbounded_channel(); + ( + BottomPane::new(BottomPaneParams { + app_event_tx: AppEventSender::new(app_event_tx), + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: true, + animations_enabled: true, + skills: None, + }), + app_event_rx, + ) +} + +#[tokio::test] +async fn terminal_color_probe_waits_for_startup_sandbox_choice() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + while app_event_rx.try_recv().is_ok() {} + + assert!(app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ true)); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + .expect("auto preset"); + app.chat_widget + .open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); + + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert!(!app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); + assert!(app_event_rx.try_recv().is_err()); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!( + app_event_rx.try_recv(), + Ok(AppEvent::BeginWindowsSandboxLegacySetup { .. }) + )); + assert!(app.ready_for_terminal_color_probe(/*has_pending_app_events*/ false)); +} + +#[test] +fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() { + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::StartFresh), + true + ); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Exit), + true + ); + assert!(!App::should_wait_for_initial_session( + &SessionSelection::AgentsOverview + )); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Resume( + crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/restore")), + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + } + )), + false + ); + assert_eq!( + App::should_wait_for_initial_session(&SessionSelection::Fork( + crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/fork")), + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + } + )), + false + ); +} + +#[test] +fn startup_paused_goal_prompt_gate_is_only_for_quiet_resume() { + let resume = SessionSelection::Resume(crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/restore")), + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + }); + let fork = SessionSelection::Fork(crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/fork")), + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + }); + let no_images: Vec = Vec::new(); + let initial_images = vec![PathBuf::from("/tmp/image.png")]; + + assert!(App::should_prompt_for_paused_goal_after_startup_resume( + &resume, &None, &no_images + )); + assert!(!App::should_prompt_for_paused_goal_after_startup_resume( + &resume, + &Some("continue from here".to_string()), + &no_images + )); + assert!(!App::should_prompt_for_paused_goal_after_startup_resume( + &resume, + &None, + &initial_images + )); + assert!(!App::should_prompt_for_paused_goal_after_startup_resume( + &SessionSelection::StartFresh, + &None, + &no_images + )); + assert!(!App::should_prompt_for_paused_goal_after_startup_resume( + &fork, &None, &no_images + )); +} + +#[test] +fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() { + let mut wait_for_initial_session = + App::should_wait_for_initial_session(&SessionSelection::StartFresh); + assert_eq!(wait_for_initial_session, true); + assert_eq!( + App::should_handle_active_thread_events( + wait_for_initial_session, + /*has_active_thread_receiver*/ true + ), + false + ); + + assert_eq!( + App::should_stop_waiting_for_initial_session( + wait_for_initial_session, + /*primary_thread_id*/ None + ), + false + ); + if App::should_stop_waiting_for_initial_session(wait_for_initial_session, Some(ThreadId::new())) + { + wait_for_initial_session = false; + } + assert_eq!(wait_for_initial_session, false); + + assert_eq!( + App::should_handle_active_thread_events( + wait_for_initial_session, + /*has_active_thread_receiver*/ true + ), + true + ); +} + +#[test] +fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() { + let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume( + crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/restore")), + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + }, + )); + assert_eq!( + App::should_handle_active_thread_events( + wait_for_resume, + /*has_active_thread_receiver*/ true + ), + true + ); + let wait_for_fork = App::should_wait_for_initial_session(&SessionSelection::Fork( + crate::resume_picker::SessionTarget { + path: Some(PathBuf::from("/tmp/fork")), + thread_id: ThreadId::new(), + cwd: None, + history_mode: None, + }, + )); + assert_eq!( + App::should_handle_active_thread_events( + wait_for_fork, + /*has_active_thread_receiver*/ true + ), + true + ); +} + +#[tokio::test] +async fn queued_startup_requests_block_terminal_input_after_draft_handoff() -> Result<()> { + for user_input in [false, true] { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert!(pending_startup_draft.is_none()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft"); + assert!(!app.has_queued_startup_protected_request()); + + let request = if user_input { + request_user_input_request(thread_id, "turn-1", "call-1") + } else { + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None) + }; + let _ = app + .pending_app_server_requests + .note_server_request(&request); + app.enqueue_primary_thread_request(request).await?; + + assert!( + app.active_thread_rx + .as_ref() + .is_some_and(|receiver| !receiver.is_empty()) + ); + assert!( + app.has_queued_startup_protected_request(), + "a queued protected startup request must be handled before terminal input" + ); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft"); + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!( + op, + Op::UserTurn { .. } | Op::ExecApproval { .. } | Op::UserInputAnswer { .. } + ), + "terminal input must not submit or answer the queued protected request: {op:?}" + ); + } + + let event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("protected request should be queued on the active thread"); + app.handle_thread_event_now(event); + + assert!( + !app.has_queued_startup_protected_request(), + "terminal polling must resume after the protected event is drained" + ); + assert!(app.startup_protected_input_boundary); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft"); + + app.startup_protected_input_boundary = false; + app.enqueue_primary_thread_request(exec_approval_request( + thread_id, "turn-2", "call-2", /*approval_id*/ None, + )) + .await?; + assert!( + !app.has_queued_startup_protected_request(), + "ordinary post-startup protected requests must not block terminal input" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn queued_startup_requests_block_draft_restore_until_drained() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + let request = exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&request); + app.enqueue_primary_thread_request(request).await?; + + assert!(app.has_queued_startup_protected_request()); + + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + while app_event_rx.try_recv().is_ok() {} + assert!(app.has_queued_startup_protected_request()); + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + if app_event_rx.is_empty() && !app.has_queued_startup_protected_request() { + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + } + + assert!(pending_startup_draft.is_some()); + assert!(app.chat_widget.composer_is_empty()); + + let event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("protected request should be queued on the active thread"); + app.handle_thread_event_now(event); + + assert!(!app.has_queued_startup_protected_request()); + assert!(app.chat_widget.has_active_view()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert!(pending_startup_draft.is_some()); + assert!(app.chat_widget.composer_is_empty()); + + Ok(()) +} + +#[tokio::test] +async fn startup_draft_handoff_keeps_approval_shortcuts_in_recently_active_composer() -> Result<()> +{ + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + + assert!(pending_startup_draft.is_none()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "x"); + + let approval_request = + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&approval_request); + app.enqueue_primary_thread_request(approval_request).await?; + let approval_event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("approval should be queued on the active thread"); + app.handle_thread_event_now(approval_event); + + assert!(!app.chat_widget.has_active_view()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(app.chat_widget.composer_text_with_pending(), "xy"); + + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::SubmitThreadOp { .. }), + "startup typeahead should not approve a newly buffered request: {event:?}" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn startup_draft_delayed_approval_becomes_protected_on_redraw() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:1".into(), + auth_token: None, + }, + }; + app.begin_reconnect(); + assert!(app.startup_protected_input_boundary); + // The replacement connection replays the protected request after the old request was dropped. + app.reconnect.offline = false; + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new()); + let mut draft = startup_pane.composer_draft_snapshot(); + draft.last_composer_activity_at = Some(Instant::now() - Duration::from_millis(/*millis*/ 950)); + let mut pending_startup_draft = Some(draft); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + + let approval_request = + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&approval_request); + app.enqueue_primary_thread_request(approval_request).await?; + let approval_event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("approval should be queued on the active thread"); + app.handle_thread_event_now(approval_event); + assert!(!app.chat_widget.has_active_view()); + assert!(app.startup_pending_protected_request); + + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)), + ) + .await?; + assert!(app.startup_protected_input_boundary); + assert!(app.startup_pending_protected_request); + + tokio::time::sleep(Duration::from_millis(/*millis*/ 75)).await; + let redraw_result = app + .handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await; + + assert!(app.chat_widget.has_active_view()); + assert!(!tui.terminal.viewport_area.is_empty()); + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::SubmitThreadOp { .. }), + "revealing a delayed startup approval must not submit a decision: {event:?}" + ); + } + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }), + "revealing a delayed startup approval must not answer it: {op:?}" + ); + } + if let Err(error) = redraw_result { + tracing::debug!(error = %error, "test terminal cannot quarantine interactive input"); + } else { + assert!(!app.startup_pending_protected_request); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn remotely_resolved_startup_approvals_release_the_draft_after_the_last_request() -> Result<()> +{ + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + let app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + app.chat_widget.handle_paste("x".to_string()); + + for (request_id, item_id) in [(1, "call-1"), (2, "call-2")] { + let mut request = + exec_approval_request(thread_id, "turn-1", item_id, /*approval_id*/ None); + let ServerRequest::CommandExecutionRequestApproval { + request_id: approval_request_id, + .. + } = &mut request + else { + unreachable!("exec approval helper should return an exec approval"); + }; + *approval_request_id = AppServerRequestId::Integer(request_id); + let _ = app + .pending_app_server_requests + .note_server_request(&request); + app.handle_thread_event_now(ThreadBufferedEvent::Request(Box::new(request))); + } + + assert!(!app.chat_widget.has_active_view()); + assert!(app.startup_pending_protected_request); + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("startup draft".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert!(pending_startup_draft.is_some()); + + for (request_id, still_pending) in [(1, true), (2, false)] { + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::ServerRequestResolved( + codex_app_server_protocol::ServerRequestResolvedNotification { + thread_id: thread_id.to_string(), + request_id: AppServerRequestId::Integer(request_id), + }, + ), + )), + ) + .await; + + assert_eq!(app.startup_pending_protected_request, still_pending); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert_eq!(pending_startup_draft.is_some(), still_pending); + } + + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "x\nstartup draft" + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn later_thread_approval_preserves_input_after_startup_boundary_ends() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + assert!(!app.startup_protected_input_boundary); + app.chat_widget + .apply_external_edit("keep this draft".to_string()); + let approval_request = + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&approval_request); + app.enqueue_primary_thread_request(approval_request).await?; + let approval_event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("approval should be queued on the active thread"); + assert!(!app.chat_widget.has_active_view()); + + app.handle_active_thread_event(&mut tui, &mut app_server, approval_event) + .await?; + + assert!(app.chat_widget.has_active_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "keep this draft" + ); + assert!(tui.terminal.viewport_area.is_empty()); + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::SubmitThreadOp { .. }), + "a later protected request must not consume existing input: {event:?}" + ); + } + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }), + "a later protected request must not submit an answer: {op:?}" + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn auto_declined_mcp_elicitations_do_not_leave_startup_quarantine_armed() { + for replay in [false, true] { + for elicitation in [ + McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + McpServerElicitationRequest::OpenAiForm { + meta: None, + message: "Choose a report.".to_string(), + requested_schema: serde_json::json!({}), + }, + ] { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.startup_protected_input_boundary = true; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(10), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: elicitation, + }, + }; + let _ = app + .pending_app_server_requests + .note_server_request(&request); + let event = ThreadBufferedEvent::Request(Box::new(request)); + if replay { + app.handle_thread_event_replay(event); + } else { + app.handle_thread_event_now(event); + } + + assert!(!app.chat_widget.has_active_view()); + assert!(!app.startup_pending_protected_request); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: AppServerRequestId::Integer(10), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); + } + } +} + +#[tokio::test] +async fn startup_draft_handoff_recognizes_late_user_input_as_new_protected_view() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + + let mut request = request_user_input_request(thread_id, "turn-1", "call-1"); + let ServerRequest::ToolRequestUserInput { params, .. } = &mut request else { + panic!("expected a user input request"); + }; + params.questions.push(ToolRequestUserInputQuestion { + id: "choice".to_string(), + header: "Pick one".to_string(), + question: "Choose an option.".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "First".to_string(), + description: "First option".to_string(), + }]), + }); + let _ = app + .pending_app_server_requests + .note_server_request(&request); + app.enqueue_primary_thread_request(request).await?; + let event = app + .active_thread_rx + .as_mut() + .expect("primary thread receiver should be active") + .try_recv() + .expect("user input request should be queued on the active thread"); + + assert!(!app.chat_widget.has_active_view()); + app.handle_thread_event_now(event); + + assert!(app.chat_widget.has_active_view()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "x"); + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::CodexOp(Op::UserInputAnswer { .. })), + "showing the protected user input request must not submit an answer: {event:?}" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn fresh_startup_thread_drains_buffered_approval_before_draft_handoff() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + let thread_id = ThreadId::new(); + let approval_request = + exec_approval_request(thread_id, "turn-1", "call-1", /*approval_id*/ None); + let _ = app + .pending_app_server_requests + .note_server_request(&approval_request); + app.enqueue_primary_thread_request(approval_request).await?; + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + let mut waiting_for_initial_session_configured = + App::should_wait_for_initial_session(&SessionSelection::StartFresh); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::StartupThreadStarted { + result: Ok(AppServerStartedThread { + session: test_thread_session(thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + blocks_direct_input: false, + task_tools_available: false, + }), + }, + )) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert!( + app.active_thread_rx + .as_ref() + .is_some_and(|receiver| !receiver.is_empty()) + ); + assert!(!app.chat_widget.has_active_view()); + + if App::should_stop_waiting_for_initial_session( + waiting_for_initial_session_configured, + app.primary_thread_id, + ) { + waiting_for_initial_session_configured = false; + app.drain_active_thread_events(&mut tui).await?; + } + + assert!(!waiting_for_initial_session_configured); + assert!(app.chat_widget.has_active_view()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert_eq!( + pending_startup_draft + .as_ref() + .map(|draft| draft.text.as_str()), + Some("inspect @src") + ); + assert!(app.chat_widget.composer_is_empty()); + while let Ok(op) = op_rx.try_recv() { + assert!( + !matches!(op, Op::ExecApproval { .. } | Op::UserInputAnswer { .. }), + "showing a protected startup approval must not answer it: {op:?}" + ); + } + + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!( + event, + AppEvent::StartFileSearch(_) | AppEvent::SubmitThreadOp { .. } + ), + "fresh startup approval must own input before draft side effects: {event:?}" + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn queued_startup_app_event_owns_protected_view_before_draft_restore() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + while app_event_rx.try_recv().is_ok() {} + + let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); + startup_pane.set_composer_text("inspect @src".to_string(), Vec::new(), Vec::new()); + let mut pending_startup_draft = Some(startup_pane.composer_draft_snapshot()); + app.app_event_tx.send(AppEvent::OpenApprovalsPopup); + + if app_event_rx.is_empty() { + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + } + assert!(pending_startup_draft.is_some()); + assert!(app.chat_widget.composer_is_empty()); + + let event = app_event_rx + .try_recv() + .expect("protected startup app event should be queued"); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + assert!(!app.chat_widget.has_active_view()); + let control = Box::pin(app.handle_event(&mut tui, &mut app_server, event)).await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert!(app.chat_widget.has_active_view()); + app.chat_widget + .restore_startup_draft_when_ready(&mut pending_startup_draft); + assert_eq!( + pending_startup_draft + .as_ref() + .map(|draft| draft.text.as_str()), + Some("inspect @src") + ); + assert!(app.chat_widget.composer_is_empty()); + + while let Ok(event) = app_event_rx.try_recv() { + assert!( + !matches!(event, AppEvent::StartFileSearch(_)), + "protected startup app event must own input before draft side effects: {event:?}" + ); + } + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn known_thread_started_preserves_session_without_reading_unmaterialized_rollout() { + use futures::FutureExt as _; + + let mut app = make_test_app().await; + let temp_dir = tempfile::tempdir().expect("temp dir"); + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, temp_dir.path().to_path_buf()); + app.primary_session_configured = Some(session.clone()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + session.clone(), + Vec::new(), + ), + ); + let notification = ThreadStartedNotification { + thread: Thread { + originator: None, + environments: None, + id: thread_id.to_string(), + extra: None, + session_id: thread_id.to_string(), + forked_from_id: None, + parent_thread_id: None, + preview: String::new(), + ephemeral: false, + section: None, + section_entered_at: None, + project_id: None, + daybreak_enabled: None, + history_mode: Default::default(), + model_provider: "notification-provider".to_string(), + model: None, + reasoning_effort: None, + created_at: 1, + updated_at: 2, + recency_at: Some(2), + status: codex_app_server_protocol::ThreadStatus::Idle, + path: Some(temp_dir.path().join("not-yet-materialized.jsonl")), + cwd: session.cwd.clone(), + cli_version: "0.0.0".to_string(), + source: codex_app_server_protocol::SessionSource::Unknown, + can_accept_direct_input: None, + thread_source: None, + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + git_info: None, + name: Some("notification title".to_string()), + turns: Vec::new(), + }, + }; + + tokio::task::unconstrained(app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadStarted(notification.clone()), + )) + .now_or_never() + .expect("known sessions must not wait for rollout reads") + .expect("thread notification should be routed"); + + let store = app.thread_event_channels[&thread_id].store.lock().await; + assert_eq!(store.session, Some(session)); + let Some(ThreadBufferedEvent::Notification(buffered)) = store.buffer.back() else { + panic!("thread started notification should remain buffered"); + }; + let ServerNotification::ThreadStarted(buffered) = buffered.as_ref() else { + panic!("buffered notification should be thread started"); + }; + assert_eq!(buffered, ¬ification); + drop(store); + assert_eq!( + app.agent_navigation.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + agent_path: None, + is_running: false, + is_closed: false, + }) + ); +} + +#[tokio::test] +async fn startup_thread_started_submits_queued_startup_input() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + app.chat_widget + .set_queue_submissions_until_session_configured(/*queue*/ true); + app.chat_widget + .apply_external_edit("queued before startup completes".to_string()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + app.chat_widget.queued_user_message_texts(), + vec!["queued before startup completes".to_string()] + ); + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + let thread_id = ThreadId::new(); + app.handle_startup_thread_started( + &mut app_server, + Ok(AppServerStartedThread { + session: test_thread_session(thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + blocks_direct_input: false, + task_tools_available: false, + }), + ) + .await + .expect("startup thread should attach"); + + match next_user_turn_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "queued before startup completes".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued startup input submission, got {other:?}"), + } +} + +#[tokio::test] +async fn fresh_startup_notice_follows_session_attachment() { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + app.pending_server_version_notice = + Some(crate::status::remote_connection::ServerVersionNotice { + message: "Older server notice".to_string(), + offer_update: false, + }); + assert!(events.try_recv().is_err()); + + let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + app.handle_startup_thread_started( + &mut app_server, + Ok(AppServerStartedThread { + session: test_thread_session(ThreadId::new(), test_path_buf("/tmp/project")), + turns: Vec::new(), + blocks_direct_input: false, + task_tools_available: false, + }), + ) + .await + .expect("startup thread should attach"); + + let cells = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .collect::>(); + assert!(cells.len() > 1, "session history should precede the notice"); + insta::assert_snapshot!(lines_to_single_string(&cells.last().unwrap().display_lines(/*width*/ 80)), @"⚠ Older server notice"); + assert_eq!(app.pending_server_version_notice, None); +} + +#[tokio::test] +async fn remote_overview_startup_hides_disabled_older_server_notice() -> Result<()> { + let mut app = make_test_app().await; + app.local_settings.tui.show_server_version_notice = false; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + app.app_server_target = AppServerTarget::Remote { + endpoint: endpoint.clone(), + }; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + super::disconnect::serve_reconnect_requests( + tokio_tungstenite::accept_async(stream).await?, + |_request| std::future::ready(None), + ) + .await + }); + let session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Remote, + ); + + assert_eq!( + app.initialize_server_version_notice("2.1.0", session.server_version()), + None + ); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); + insta::assert_snapshot!(rendered.lines().take(2).collect::>().join("\n"), @" Agent command center + 0 need input 0 working 0 ready"); + app.chat_widget.remote_connection = + crate::status::remote_connection::remote_connection_status_value( + &app.app_server_target, + session.server_version(), + ); + assert_eq!( + app.chat_widget + .remote_connection + .as_ref() + .expect("remote status") + .version, + "v2.0.0" + ); + app.local_settings.tui.show_server_version_notice = true; + app.refresh_server_version_overview_notice("2.1.0"); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); + insta::assert_snapshot!(rendered.lines().take(2).collect::>().join("\n"), @" Service v2.0.0 < Codex CLI v2.1.0 + 0 need input 0 working 0 ready"); + app.pending_server_version_notice = + Some(crate::status::remote_connection::ServerVersionNotice { + message: "Older service".to_string(), + offer_update: false, + }); + app.reconnect.seen_version_notice = Some("old-key".to_string()); + app.local_settings.tui.show_server_version_notice = false; + app.refresh_server_version_overview_notice("2.1.0"); + assert_eq!(app.pending_server_version_notice, None); + assert_eq!(app.reconnect.seen_version_notice, None); + assert_eq!( + app.agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .server_version_notice, + None + ); + session.shutdown().await?; + server.await??; + Ok(()) +} + +#[tokio::test] +async fn startup_thread_started_discards_another_threads_buffered_events() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + let other_thread_id = ThreadId::new(); + app.enqueue_primary_thread_notification(ServerNotification::McpServerStatusUpdated( + McpServerStatusUpdatedNotification { + thread_id: Some(other_thread_id.to_string()), + name: "other".to_string(), + status: McpServerStartupState::Starting, + error: None, + failure_reason: None, + }, + )) + .await + .expect("foreign notification should be buffered"); + + let request = ServerRequest::CommandExecutionRequestApproval { + request_id: AppServerRequestId::Integer(1), + params: CommandExecutionRequestApprovalParams { + kind: Default::default(), + thread_id: other_thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + started_at_ms: 0, + approval_id: None, + environment_id: None, + reason: None, + network_approval_context: None, + command: None, + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + }; + app.pending_app_server_requests + .note_server_request(&request); + app.enqueue_primary_thread_request(request) + .await + .expect("foreign request should be buffered"); + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new( + ServerRequest::CurrentTimeRead { + request_id: AppServerRequestId::Integer(2), + params: codex_app_server_protocol::CurrentTimeReadParams { + thread_id: other_thread_id.to_string(), + }, + }, + )), + ) + .await; + assert!(app.pending_primary_events.iter().any(|event| { + matches!(event, ThreadBufferedEvent::Request(request) + if matches!(request.as_ref(), ServerRequest::CurrentTimeRead { .. })) + })); + let thread_id = ThreadId::new(); + app.handle_startup_thread_started( + &mut app_server, + Ok(AppServerStartedThread { + session: test_thread_session(thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + blocks_direct_input: false, + task_tools_available: false, + }), + ) + .await + .expect("startup thread should attach"); + + assert!(app.pending_primary_events.is_empty()); + assert!( + app.active_thread_rx + .as_mut() + .is_some_and(|rx| rx.try_recv().is_err()) + ); +} + +#[tokio::test] +async fn startup_thread_started_does_not_replay_resolved_approval() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + let app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let thread_id = ThreadId::new(); + let request = exec_approval_request(thread_id, "turn-1", "item-1", /*approval_id*/ None); + let request_id = request.id().clone(); + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new(request.clone())), + ) + .await; + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification(Box::new( + ServerNotification::ServerRequestResolved( + codex_app_server_protocol::ServerRequestResolvedNotification { + thread_id: thread_id.to_string(), + request_id, + }, + ), + )), + ) + .await; + + let mut app_server = app_server; + app.handle_startup_thread_started( + &mut app_server, + Ok(AppServerStartedThread { + session: test_thread_session(thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + blocks_direct_input: false, + task_tools_available: false, + }), + ) + .await?; + + assert!( + !app.pending_app_server_requests + .contains_server_request(&request) + ); + Ok(()) +} + +#[tokio::test] +async fn owned_subagent_approval_before_thread_started_is_preserved() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let parent = app_server.start_thread(&app.config).await?; + let parent_thread_id = parent.session.thread_id; + app.enqueue_primary_thread_session(parent.session, parent.turns) + .await?; + let child_thread_id = ThreadId::from_string( + &create_fake_parented_rollout_with_source( + codex_home.path(), + "2026-01-01T00-00-01", + "2026-01-01T00:00:01Z", + "child task", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + RolloutSessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + parent_thread_id.into(), + parent_thread_id, + ) + .expect("create child rollout"), + )?; + app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + child_thread_id, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ) + .await?; + let request = exec_approval_request( + child_thread_id, + "turn-1", + "item-1", + /*approval_id*/ None, + ); + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerRequest(Box::new(request.clone())), + ) + .await; + + assert!( + app.pending_app_server_requests + .contains_server_request(&request) + ); + assert!(app.thread_event_channels.contains_key(&child_thread_id)); + Ok(()) +} + +#[tokio::test] +async fn startup_thread_start_failure_returns_error() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + let mut tui = crate::tui::test_support::make_test_tui().expect("test tui"); + app.insert_history_cell( + &mut tui, + Box::new(history_cell::StartupWarningsCell::new(vec![ + "Skill manifest is invalid.".to_string(), + ])), + ); + + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await + .expect("embedded app server"); + let err = app + .handle_startup_thread_started(&mut app_server, Err(color_eyre::eyre::eyre!("boom"))) + .await + .expect_err("startup thread failure should exit instead of leaving chat unconfigured"); + + insta::assert_snapshot!(err.to_string(), @" + Failed to start a fresh session through the app server: boom + + Startup warnings: + Skill manifest is invalid. + "); + assert!(!app.pending_startup_thread_start); + assert_eq!(app.primary_thread_id, None); +} + +#[test] +fn stale_startup_thread_started_removes_local_routing_state() -> Result<()> { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_stack_size(8 * 1024 * 1024) + .enable_all() + .build()? + .block_on(async { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let primary_thread_id = ThreadId::new(); + let stale_thread_id = ThreadId::new(); + app.primary_thread_id = Some(primary_thread_id); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + app.activate_thread_channel(primary_thread_id).await; + app.thread_event_channels.insert( + stale_thread_id, + ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY), + ); + app.agent_navigation.upsert( + stale_thread_id, + /*agent_nickname*/ None, + /*agent_role*/ None, + /*is_closed*/ false, + ); + assert!(app.thread_event_channels.contains_key(&stale_thread_id)); + assert!(app.agent_navigation.get(&stale_thread_id).is_some()); + + app.handle_startup_thread_started( + &mut app_server, + Ok(AppServerStartedThread { + session: test_thread_session(stale_thread_id, test_path_buf("/tmp/project")), + turns: Vec::new(), + blocks_direct_input: false, + task_tools_available: false, + }), + ) + .await?; + + assert!(!app.thread_event_channels.contains_key(&stale_thread_id)); + assert_eq!(app.agent_navigation.get(&stale_thread_id), None); + assert_eq!(app.active_thread_id, Some(primary_thread_id)); + Ok(()) + }) +} + +#[tokio::test] +async fn ignore_same_thread_resume_reports_noop_for_current_thread() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session(THREAD_EVENT_CHANNEL_CAPACITY, session, Vec::new()), + ); + app.activate_thread_channel(thread_id).await; + while app_event_rx.try_recv().is_ok() {} + + let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget { + path: Some(test_path_buf("/tmp/project")), + thread_id, + cwd: None, + history_mode: None, + }); + + assert!(ignored); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected info message after same-thread resume, saw {other:?}"), + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!(rendered.contains(&format!( + "Already viewing {}.", + test_path_display("/tmp/project") + ))); +} + +#[tokio::test] +async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); + app.chat_widget.handle_thread_session(session); + + let ignored = app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget { + path: Some(test_path_buf("/tmp/project")), + thread_id, + cwd: None, + history_mode: None, + }); + + assert!(!ignored); + assert!(app.transcript_cells.is_empty()); +} + +#[tokio::test] +async fn ignore_same_thread_resume_allows_retrying_read_only_view() -> Result<()> { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); + let mut channel = ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + session.clone(), + Vec::new(), + ); + channel.mark_external_writer(); + app.thread_event_channels.insert(thread_id, channel); + app.activate_thread_channel(thread_id).await; + + assert!( + !app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget { + path: Some(test_path_buf("/tmp/project")), + thread_id, + cwd: None, + history_mode: None, + }) + ); + let app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.render_thread_snapshot( + &mut tui, + &app_server, + thread_id, + ThreadEventSnapshot { + delegated_turns: Vec::new(), + session: Some(session), + turns: vec![test_turn("running", TurnStatus::InProgress, Vec::new())], + events: Vec::new(), + active_reasoning_item: None, + input_state: None, + }, + /*resume_restored_queue*/ false, + )?; + assert!(app.chat_widget.is_external_writer_view()); + assert!(!app.chat_widget.is_task_running_for_test()); + Ok(()) +} + +#[tokio::test] +async fn external_writer_startup_keeps_initial_prompt_as_draft() -> Result<()> { + let (mut app, mut events, _operations) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let prompt = "Continue after the other app closes"; + set_test_initial_prompt(&mut app, prompt.to_string()); + app.chat_widget.show_external_writer_thread(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + + assert_eq!(app.chat_widget.composer_text_with_pending(), prompt); + assert!(!std::iter::from_fn(|| events.try_recv().ok()).any(|event| { + matches!( + event, + AppEvent::SubmitThreadOp { + op: Op::UserTurn { .. }, + .. + } | AppEvent::CodexOp(Op::UserTurn { .. }) + ) + })); + Ok(()) +} + +#[tokio::test] +async fn windows_sandbox_config_refresh_uses_connected_server() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let (server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[windows]\nsandbox = \"unelevated\"\n", + )?; + app.chat_widget.windows_sandbox_config.mode = + Some(codex_app_server_protocol::WindowsSandboxSetupMode::Elevated); + assert!(app.refresh_windows_sandbox_config(&server).await); + let loaded_config = crate::windows_sandbox::WindowsSandboxConfig { + mode: Some(codex_app_server_protocol::WindowsSandboxSetupMode::Unelevated), + requirements: Some(None), + }; + assert_eq!(app.chat_widget.windows_sandbox_config, loaded_config); + assert_eq!( + recorded_params(&requests, "config/read")[0]["cwd"], + app.config.cwd.display().to_string() + ); + assert_eq!( + recorded_params(&requests, "configRequirements/read").len(), + 1 + ); + proxy.abort(); + let _ = proxy.await; + set_test_initial_prompt(&mut app, "keep this draft".to_string()); + app.chat_widget.windows_sandbox_elevated_setup_complete = true; + while events.try_recv().is_ok() {} + assert!(!app.refresh_windows_sandbox_config(&server).await); + let cell = match events.try_recv()? { + AppEvent::InsertHistoryCell(cell) => cell, + other => panic!("expected configuration error message, got {other:?}"), + }; + insta::assert_snapshot!(lines_to_single_string(&cell.display_lines(/*width*/ 160)), @"■ Could not read Windows sandbox configuration and requirements from the app server."); + assert!(app.chat_widget.windows_sandbox_elevated_setup_complete); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "keep this draft" + ); + assert!(app.chat_widget.initial_user_message.is_none()); + let mut recovered = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.windows_sandbox_local_server = true; + app.chat_widget.windows_sandbox_host = WindowsSandboxHost::Local; + Box::pin(app.handle_event(&mut tui, &mut recovered, AppEvent::OpenPermissionsPopup)).await?; + assert_eq!(app.chat_widget.windows_sandbox_config, loaded_config); + assert!(app.chat_widget.has_active_view()); + recovered.shutdown().await?; + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d3ee2165a9ebab510992f0137df14e41007ceff --- /dev/null +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -0,0 +1,530 @@ +//! Request-level coverage for the fresh-startup server defaults overlay. + +use super::*; +use crate::app::startup::prepare_fresh_startup_config; +use crate::app::startup::startup_model; +use pretty_assertions::assert_eq; + +async fn run_startup_for_test( + tui: &mut crate::tui::Tui, + server: AppServerSession, + config: Config, + bootstrap: AppServerBootstrap, + selection: SessionSelection, +) -> Result { + App::run( + tui, + server, + config.clone(), + config.cwd.to_path_buf(), + Vec::new(), + ConfigOverrides::default(), + LoaderOverrides::default(), + CloudConfigBundleLoader::default(), + /*initial_prompt*/ None, + Vec::new(), + selection, + codex_feedback::CodexFeedback::new(), + /*is_first_run*/ false, + /*should_prompt_windows_sandbox_nux_at_startup*/ false, + AppServerTarget::Embedded, + /*state_db*/ None, + Arc::new(EnvironmentManager::default_for_tests()), + Duration::ZERO, + Some(bootstrap), + /*startup_hooks_browser*/ None, + crate::startup_draft::tests::quiet_startup_test_pump(), + /*managed_worktree*/ None, + /*daemon_cli_executable*/ None, + ) + .await +} + +#[tokio::test] +async fn cli_fork_omits_implicit_model_and_effort() -> Result<()> { + let home = tempdir()?; + std::fs::write( + home.path().join("config.toml"), + "model = \"gpt-5.5\"\nmodel_reasoning_effort = \"low\"\nfeatures.fast_mode = true\n", + )?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + let source = ThreadId::from_string( + &app_test_support::create_fake_rollout( + home.path(), + "2026-01-01T00-00-00", + "2026-01-01T00:00:00Z", + "source prompt", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let (mut server, requests, proxy) = start_recording_remote_app_server(&config).await?; + let mut bootstrap = server.bootstrap(&config).await?; + let mut target_preset = bootstrap + .available_models + .iter() + .find(|preset| preset.show_in_picker) + .expect("visible migration target") + .clone(); + target_preset.id = "server-model".into(); + target_preset.model = "server-model".into(); + bootstrap.available_models.push(target_preset); + let client_preset = bootstrap + .available_models + .iter_mut() + .find(|preset| preset.model == "gpt-5.5") + .expect("client model in catalog"); + client_preset.upgrade = Some(codex_protocol::openai_models::ModelUpgrade { + id: "server-model".into(), + migration_config_key: "test-fork-migration".into(), + model_link: None, + upgrade_copy: None, + migration_markdown: None, + retirement_at: None, + }); + assert!( + crate::app::startup_prompts::should_show_model_migration_prompt( + "gpt-5.5", + "server-model", + &Default::default(), + &bootstrap.available_models, + ) + ); + std::fs::write( + home.path().join("config.toml"), + "model = \"server-model\"\nmodel_reasoning_effort = \"high\"\n", + )?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut run = Box::pin(run_startup_for_test( + &mut tui, + server, + config, + bootstrap, + SessionSelection::Fork(crate::resume_picker::SessionTarget { + path: None, + thread_id: source, + cwd: None, + history_mode: None, + }), + )); + tokio::time::timeout(Duration::from_secs(/*secs*/ 15), async { + loop { + if !recorded_params(&requests, "thread/fork").is_empty() { + return Ok::<(), color_eyre::eyre::Report>(()); + } + tokio::select! { + result = &mut run => { + result?; + return Err(color_eyre::eyre::eyre!("startup exited before thread/fork")); + } + () = tokio::time::sleep(Duration::from_millis(/*millis*/ 20)) => {} + } + } + }) + .await??; + let fork = recorded_params(&requests, "thread/fork"); + assert_eq!(fork.len(), 1); + assert_eq!(fork[0]["model"], serde_json::Value::Null); + assert!(fork[0]["config"].get("model_reasoning_effort").is_none()); + assert_eq!(fork[0]["serviceTier"], serde_json::Value::Null); + assert!(recorded_params(&requests, "config/read").is_empty()); + drop(run); + proxy.abort(); + Ok(()) +} + +async fn run_until_thread_start( + server: AppServerSession, + config: Config, + bootstrap: AppServerBootstrap, + requests: &RecordedRequests, +) -> Result<()> { + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); + let mut run = Box::pin(run_startup_for_test( + &mut tui, + server, + config, + bootstrap, + SessionSelection::StartFresh, + )); + tokio::time::timeout(Duration::from_secs(/*secs*/ 15), async { + loop { + if !recorded_params(requests, "thread/start").is_empty() { + return Ok::<(), color_eyre::eyre::Report>(()); + } + tokio::select! { + result = &mut run => { + result?; + return Err(color_eyre::eyre::eyre!("startup exited before thread/start")); + } + () = tokio::time::sleep(Duration::from_millis(/*millis*/ 20)) => {} + } + } + }) + .await??; + Ok(()) +} + +#[tokio::test] +async fn fresh_startup_uses_server_defaults_with_explicit_and_managed_precedence() -> Result<()> { + for (choice, managed, expected_model, expected_effort) in [ + ("saved", false, "server-model", "high"), + ("cli_model", true, "cli-model", "high"), + ("cli_effort", true, "server-model", "low"), + ("profile_model", false, "profile-model", "high"), + ("profile_effort", false, "server-model", "low"), + ("profile_model", true, "profile-model", "high"), + ("profile_effort", true, "server-model", "low"), + ("managed", true, "managed-model", "medium"), + ] { + let client_home = tempdir()?; + let server_home = tempdir()?; + std::fs::write( + client_home.path().join("config.toml"), + "model = \"client-model\"\nmodel_reasoning_effort = \"low\"\n", + )?; + std::fs::write( + server_home.path().join("config.toml"), + "model = \"server-model\"\nmodel_reasoning_effort = \"high\"\n", + )?; + if managed { + std::fs::write( + server_home.path().join("requirements.toml"), + "[models.new_thread]\nmodel = \"managed-model\"\nmodel_reasoning_effort = \"medium\"\n", + )?; + } + let mut harness_overrides = ConfigOverrides::default(); + let mut cli_kv_overrides = Vec::new(); + let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + match choice { + "cli_model" => harness_overrides.model = Some("cli-model".to_string()), + "cli_effort" => cli_kv_overrides.push(( + "model_reasoning_effort".to_string(), + TomlValue::String("low".to_string()), + )), + "profile_model" | "profile_effort" => { + let path = client_home.path().join("work.config.toml"); + std::fs::write( + &path, + if choice == "profile_model" { + "model = \"profile-model\"\n" + } else { + "model_reasoning_effort = \"low\"\n" + }, + )?; + loader_overrides.user_config_path = Some(path.abs()); + loader_overrides.user_config_profile = Some("work".parse()?); + } + _ => {} + } + let mut config = ConfigBuilder::default() + .codex_home(client_home.path().to_path_buf()) + .loader_overrides(loader_overrides) + .cli_overrides(cli_kv_overrides.clone()) + .harness_overrides(harness_overrides.clone()) + .build() + .await?; + let mut server_config = config.clone(); + server_config.codex_home = server_home.path().to_path_buf().abs(); + server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs()); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &server_config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Remote, + LoaderOverrides { + user_config_path: Some(server_home.path().join("config.toml").abs()), + system_requirements_path: Some(server_home.path().join("requirements.toml")), + ..LoaderOverrides::default() + }, + ) + .await?; + server = server.with_remote_cwd_override(Some(server_config.cwd.to_path_buf())); + let bootstrap = server.bootstrap(&config).await?; + assert!( + prepare_fresh_startup_config( + &mut config, + &server, + &cli_kv_overrides, + &harness_overrides, + ) + .await? + ); + let selected_model = startup_model(&config, &bootstrap, /*server_defaults_read*/ true); + let started = crate::app_server_session::start_thread_with_request_handle( + server.request_handle(), + &crate::local_settings::LocalSettings::from(&config), + config, + server.thread_params_mode(), + server.remote_cwd_override().map(Path::to_path_buf), + server.thread_tool_transport(), + ) + .await?; + assert_eq!(selected_model, expected_model, "{choice}"); + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1, "{choice}"); + assert_eq!( + ( + &starts[0]["model"], + &starts[0]["config"]["model_reasoning_effort"] + ), + ( + &serde_json::json!(expected_model), + &serde_json::json!(expected_effort) + ), + "{choice}" + ); + assert_eq!(started.session.model, expected_model, "{choice}"); + assert_eq!( + recorded_params(&requests, "config/read"), + vec![serde_json::json!({"cwd": server_config.cwd.display().to_string()})], + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Result<()> { + for (remote, override_cwd) in [(false, false), (true, false), (true, true)] { + let client_home = tempdir()?; + let server_home = tempdir()?; + let destination = tempdir()?; + let launch_cwd = tempdir()?; + std::fs::write( + client_home.path().join("config.toml"), + "model = \"stale-client-model\"\nmodel_reasoning_effort = \"low\"\n", + )?; + std::fs::write( + server_home.path().join("config.toml"), + "model_reasoning_effort = \"high\"\n", + )?; + let mut config = ConfigBuilder::default() + .codex_home(client_home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(ConfigOverrides { + cwd: Some(destination.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let mut server_config = config.clone(); + server_config.codex_home = server_home.path().to_path_buf().abs(); + server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs()); + let mode = if remote { + crate::app_server_session::ThreadParamsMode::Remote + } else { + crate::app_server_session::ThreadParamsMode::Embedded + }; + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &server_config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + mode, + LoaderOverrides { + user_config_path: Some(server_home.path().join("config.toml").abs()), + ..LoaderOverrides::default() + }, + ) + .await?; + if override_cwd { + server = server.with_remote_cwd_override(Some(launch_cwd.path().to_path_buf())); + } + let bootstrap = server.bootstrap(&config).await?; + assert_eq!(bootstrap.default_model, "stale-client-model"); + let defaults_read = + prepare_fresh_startup_config(&mut config, &server, &[], &ConfigOverrides::default()) + .await?; + assert!(defaults_read); + assert_eq!(config.model, None); + let selected_model = startup_model(&config, &bootstrap, defaults_read); + assert_ne!(selected_model, "stale-client-model"); + let started = crate::app_server_session::start_thread_with_request_handle( + server.request_handle(), + &crate::local_settings::LocalSettings::from(&config), + config, + server.thread_params_mode(), + server.remote_cwd_override().map(Path::to_path_buf), + server.thread_tool_transport(), + ) + .await?; + assert_eq!(started.session.model, selected_model); + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1); + assert_eq!(starts[0]["model"], serde_json::Value::Null); + assert_eq!(starts[0]["config"]["model_reasoning_effort"], "high"); + let (mut app, _, _) = make_test_app_with_channels().await; + app.chat_widget.handle_thread_session_quiet(started.session); + if !remote { + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80) + .replace(&destination.path().display().to_string(), ""); + insta::assert_snapshot!(rendered, @r" + › Ask Codex to do anything + + gpt-6-astra high · + "); + } + let expected_cwd = if override_cwd { + launch_cwd.path().display().to_string() + } else if !remote { + destination.path().display().to_string() + } else { + ".".to_string() + }; + assert_eq!( + recorded_params(&requests, "config/read"), + vec![serde_json::json!({"cwd": expected_cwd})] + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} + +#[tokio::test] +async fn fresh_startup_falls_back_only_for_unsupported_config_read() -> Result<()> { + for capabilities in [ + HistoryCapabilities::ConfigReadUnsupported(-32601), + HistoryCapabilities::ConfigReadUnsupported(-32600), + ] { + let home = tempdir()?; + std::fs::write( + home.path().join("config.toml"), + "model = \"client-model\"\nmodel_reasoning_effort = \"low\"\n", + )?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &config, + capabilities, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let bootstrap = server.bootstrap(&config).await?; + run_until_thread_start(server, config, bootstrap, &requests).await?; + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1); + assert_eq!( + ( + &starts[0]["model"], + &starts[0]["config"]["model_reasoning_effort"] + ), + ( + &serde_json::json!("client-model"), + &serde_json::json!("low") + ) + ); + tokio::time::timeout(Duration::from_secs(/*secs*/ 15), proxy).await???; + } + Ok(()) +} + +#[tokio::test] +async fn startup_reads_server_defaults_before_starting_thread() -> Result<()> { + let client_home = tempdir()?; + let server_home = tempdir()?; + std::fs::write( + client_home.path().join("config.toml"), + "model = \"client-model\"\nmodel_reasoning_effort = \"low\"\n", + )?; + std::fs::write( + server_home.path().join("config.toml"), + "model = \"server-model\"\nmodel_reasoning_effort = \"high\"\n", + )?; + let config = ConfigBuilder::default() + .codex_home(client_home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + let mut server_config = config.clone(); + server_config.codex_home = server_home.path().to_path_buf().abs(); + server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs()); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &server_config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides { + user_config_path: Some(server_home.path().join("config.toml").abs()), + ..LoaderOverrides::default() + }, + ) + .await?; + let bootstrap = server.bootstrap(&config).await?; + run_until_thread_start(server, config, bootstrap, &requests).await?; + { + let recorded = requests.lock().expect("request recorder lock"); + let read = recorded + .iter() + .position(|request| request.method == "config/read") + .expect("startup must read server defaults"); + let start = recorded + .iter() + .position(|request| request.method == "thread/start") + .expect("startup must start a thread"); + assert!(read < start); + } + let starts = recorded_params(&requests, "thread/start"); + assert_eq!(starts.len(), 1); + assert_eq!( + ( + &starts[0]["model"], + &starts[0]["config"]["model_reasoning_effort"] + ), + ( + &serde_json::json!("server-model"), + &serde_json::json!("high") + ), + ); + tokio::time::timeout(Duration::from_secs(/*secs*/ 15), proxy).await???; + Ok(()) +} + +#[tokio::test] +async fn startup_read_failure_exits_before_thread_creation() -> Result<()> { + let (app, _, _) = make_test_app_with_channels().await; + let home = tempdir()?; + let mut config = app.config.clone(); + config.codex_home = home.path().to_path_buf().abs(); + config.sqlite = SqliteConfig::new_for_testing(home.path().abs()); + let (mut server, requests, proxy) = start_recording_app_server_with_history( + &config, + HistoryCapabilities::ConfigReadFails, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::default(), + ) + .await?; + let bootstrap = server.bootstrap(&config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let result = run_startup_for_test( + &mut tui, + server, + config, + bootstrap, + SessionSelection::StartFresh, + ) + .await; + insta::assert_snapshot!(result.expect_err("startup read must fail").to_string(), @"config/read failed in TUI"); + assert_eq!(recorded_params(&requests, "config/read").len(), 1); + assert!(recorded_params(&requests, "thread/start").is_empty()); + proxy.await??; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/startup_warnings_tests.rs b/codex-rs/tui/src/app/tests/startup_warnings_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e079c86ffb0e50df8ef793a5d4dfdabff8730ad3 --- /dev/null +++ b/codex-rs/tui/src/app/tests/startup_warnings_tests.rs @@ -0,0 +1,212 @@ +//! Startup diagnostic ordering, aggregation, and transcript coverage. + +use super::*; +use crate::history_cell::StartupWarningsCell; +use codex_app_server_protocol::McpServerStartupFailureReason; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::UnboundedReceiver; + +fn insert_warnings(app: &mut App, tui: &mut tui::Tui, messages: &[&str]) { + app.insert_history_cell( + tui, + Box::new(StartupWarningsCell::new( + messages.iter().map(|message| (*message).into()).collect(), + )), + ); +} + +fn insert_splash(app: &mut App, tui: &mut tui::Tui) { + let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()); + app.insert_history_cell( + tui, + Box::new(history_cell::new_session_info( + &app.config, + &app.local_settings, + "gpt-test", + &session, + /*is_first_event*/ false, + /*tooltip_override*/ None, + /*auth_plan*/ None, + /*show_fast_status*/ false, + )), + ); +} + +fn drain_history(app: &mut App, tui: &mut tui::Tui, events: &mut UnboundedReceiver) { + while let Ok(event) = events.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + app.insert_history_cell(tui, cell); + } + } +} + +#[tokio::test] +async fn startup_warnings_preserve_stream_repair_and_backtrack_selection() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + app.config.cwd.to_path_buf(), + )); + app.transcript_cells = vec![Arc::new(UserHistoryCell { + message: "Selected prompt".into(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + spoken: false, + })]; + app.transcript_reflow.mark_resize_requested_during_stream(); + insert_warnings(&mut app, &mut tui, &["First warning"]); + assert!(app.transcript_reflow.take_stream_finish_reflow_needed()); + // Recreate the insertion case with a backtracking overlay already open. + app.transcript_cells.remove(0); + app.backtrack.overlay_preview_active = true; + app.open_transcript_overlay(&mut tui); + app.apply_backtrack_selection_internal(/*nth_user_message*/ 0); + insert_warnings(&mut app, &mut tui, &["Second warning"]); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 12, + ); + let mut buffer = Buffer::empty(area); + let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() else { + panic!("expected transcript") + }; + overlay.render(area, &mut buffer); + let highlighted = area + .positions() + .filter(|position| { + buffer[*position] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + }) + .map(|position| buffer[position].symbol()) + .collect::(); + assert!(highlighted.contains("Selected prompt"), "{highlighted}"); + Ok(()) +} + +#[tokio::test] +async fn mcp_startup_summary_counts_servers_and_sign_in_subset() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget + .set_mcp_startup_expected_servers(["alpha", "beta", "gamma"].map(str::to_owned)); + insert_splash(&mut app, &mut tui); + let sign_in = Some(McpServerStartupFailureReason::ReauthenticationRequired); + for (name, failure_reason) in [("alpha", sign_in), ("beta", sign_in), ("gamma", None)] { + app.chat_widget.handle_server_notification( + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: None, + name: name.into(), + status: McpServerStartupState::Failed, + error: Some(format!("MCP {name}: connection unavailable")), + failure_reason, + }), + /*replay_kind*/ None, + ); + } + drain_history(&mut app, &mut tui, &mut events); + assert_eq!(app.transcript_cells.len(), 2); + let warnings = &app.transcript_cells[1]; + insta::assert_debug_snapshot!("mcp_startup_summary", warnings.display_lines(/*width*/ 100)); + insta::assert_snapshot!( + "mcp_startup_details", + lines_to_single_string(&warnings.transcript_lines(/*width*/ 40)) + ); + insert_warnings(&mut app, &mut tui, &["Skill manifest is invalid."]); + insta::assert_debug_snapshot!( + "mixed_startup_summary", + app.transcript_cells[1].display_lines(/*width*/ 100) + ); + Ok(()) +} + +#[tokio::test] +async fn startup_warnings_wait_for_splash_and_coalesce_with_full_details() -> Result<()> { + let mut app = crate::app::test_support::make_test_app().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let first = "Skill manifest is invalid."; + insert_warnings(&mut app, &mut tui, &[first]); + let pending = &app.transcript_cells[0]; + assert!(pending.display_lines(/*width*/ 80).is_empty()); + + insert_splash(&mut app, &mut tui); + insert_warnings(&mut app, &mut tui, &[first, "MCP alpha failed to start."]); + + assert_eq!(app.transcript_cells.len(), 2); + let header = app.transcript_cells[0].as_any(); + assert!(header.is::()); + let display = app.transcript_cells[1].display_lines(/*width*/ 80); + let transcript = app.transcript_cells[1].transcript_lines(/*width*/ 80); + insta::assert_snapshot!(format!("display:\n{}\n\ntranscript:\n{}", lines_to_single_string(&display), lines_to_single_string(&transcript)), @" + display: + ⚠ 2 startup issues · ctrl + t for details + + transcript: + ⚠ Skill manifest is invalid. + ⚠ MCP alpha failed to start. + "); + app.keymap.app.open_transcript = vec![crate::key_hint::plain(crossterm::event::KeyCode::F(12))]; + app.merge_startup_warnings(&mut tui, &StartupWarningsCell::default()); + insta::assert_snapshot!(lines_to_single_string(&app.transcript_cells[1].display_lines(/*width*/ 80)), @"⚠ 2 startup issues · f12 for details"); + app.keymap.app.open_transcript.clear(); + app.merge_startup_warnings(&mut tui, &StartupWarningsCell::default()); + insta::assert_snapshot!(lines_to_single_string(&app.transcript_cells[1].display_lines(/*width*/ 80)), @"⚠ 2 startup issues"); + Ok(()) +} + +#[tokio::test] +async fn startup_skill_load_order_preserves_runtime_error_recurrence() -> Result<()> { + for background_first in [false, true] { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = start_config_write_test_app_server(&app).await?; + let cwd = app.config.cwd.to_path_buf(); + let response = SkillsListResponse { + data: vec![codex_app_server_protocol::SkillsListEntry { + cwd: cwd.clone(), + skills: vec![], + errors: vec![SkillErrorInfo { + path: PathBuf::from("bad/SKILL.md"), + message: "invalid description".into(), + }], + }], + }; + // Session attachment and the background startup request may finish in either order. + for background in [background_first, !background_first] { + if background { + app.handle_event( + &mut tui, + &mut server, + AppEvent::SkillsListLoaded { + cwd: cwd.clone(), + result: Ok(response.clone()), + }, + ) + .await?; + } else { + app.handle_skills_list_result(Ok(response.clone()), "initial session refresh"); + } + } + drain_history(&mut app, &mut tui, &mut events); + assert_eq!(app.transcript_cells.len(), 1); + assert!(app.transcript_cells[0].as_any().is::()); + app.handle_skills_list_result(Ok(SkillsListResponse { data: vec![] }), "clean refresh"); + app.handle_skills_list_result(Ok(response), "runtime refresh"); + drain_history(&mut app, &mut tui, &mut events); + assert_eq!(app.transcript_cells.len(), 3); + let runtime = app.transcript_cells[1..] + .iter() + .flat_map(|cell| cell.display_lines(/*width*/ 120)) + .collect::>(); + insta::allow_duplicates! { + insta::assert_snapshot!(lines_to_single_string(&runtime), @" + ⚠ Skipped loading 1 skill(s) due to invalid SKILL.md files. + ⚠ bad/SKILL.md: invalid description + "); + } + server.shutdown().await?; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/stream_animation_tests.rs b/codex-rs/tui/src/app/tests/stream_animation_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e8dbb09f04a2a0be4da720edc043eb92f5cf7b06 --- /dev/null +++ b/codex-rs/tui/src/app/tests/stream_animation_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use futures::FutureExt; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn stream_animation_preserves_pacing_and_discards_stopped_ticks() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let (replacement, _tx, _rx, _ops) = make_chatwidget_manual_with_sender().await; + time::pause(); + let started_at = time::Instant::now(); + + app.handle_event(&mut tui, &mut app_server, AppEvent::StartCommitAnimation) + .await?; + time::advance(COMMIT_ANIMATION_TICK / 2).await; + app.handle_event(&mut tui, &mut app_server, AppEvent::StartCommitAnimation) + .await?; + time::advance(COMMIT_ANIMATION_TICK).await; + let interval = app.commit_animation.as_mut().expect("animation started"); + assert_eq!( + interval.tick().now_or_never(), + Some(started_at + COMMIT_ANIMATION_TICK) + ); + + // A blocked UI consumes one overdue tick, not a burst of obsolete ticks. + time::advance(COMMIT_ANIMATION_TICK * 4).await; + assert!(interval.tick().now_or_never().is_some()); + assert!(interval.tick().now_or_never().is_none()); + let next_tick = time::Instant::now() + COMMIT_ANIMATION_TICK; + time::advance(COMMIT_ANIMATION_TICK + Duration::from_millis(/*millis*/ 1)).await; + assert_eq!(interval.tick().now_or_never(), Some(next_tick)); + + // Restart while the previous timer is overdue; only the new deadline survives. + time::advance(COMMIT_ANIMATION_TICK * 3).await; + app.handle_event(&mut tui, &mut app_server, AppEvent::StopCommitAnimation) + .await?; + assert!(app.commit_animation.is_none()); + app.handle_event(&mut tui, &mut app_server, AppEvent::StartCommitAnimation) + .await?; + let interval = app.commit_animation.as_mut().expect("animation restarted"); + assert!(interval.tick().now_or_never().is_none()); + let next_tick = time::Instant::now() + COMMIT_ANIMATION_TICK; + time::advance(COMMIT_ANIMATION_TICK + Duration::from_millis(/*millis*/ 1)).await; + assert_eq!(interval.tick().now_or_never(), Some(next_tick)); + + app.replace_chat_widget(replacement); + assert!(app.commit_animation.is_none()); + time::resume(); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/thread_usage.rs b/codex-rs/tui/src/app/tests/thread_usage.rs new file mode 100644 index 0000000000000000000000000000000000000000..a3fdbe2770dd35fb8bc347175f0d66fc6952cac8 --- /dev/null +++ b/codex-rs/tui/src/app/tests/thread_usage.rs @@ -0,0 +1,312 @@ +use super::*; +use crate::chatwidget::ThreadUsageOutcome; +use codex_app_server_client::AppServerEvent; +use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::ThreadUsage; +use codex_protocol::account::PlanType; +use pretty_assertions::assert_eq; +use ratatui::layout::Rect; +use ratatui::layout::Size; + +async fn app_with_pending_thread_usage() -> Result<(App, AppServerSession, tui::Tui, ThreadId, u64)> +{ + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.chat_widget.handle_thread_session(test_thread_session( + thread_id, + test_path_buf("/tmp/project"), + )); + app.chat_widget.update_account_state( + /*status_account_display*/ None, + Some(PlanType::Business), + /*has_chatgpt_account*/ false, + /*has_codex_backend_auth*/ true, + ); + while app_event_rx.try_recv().is_ok() {} + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.terminal.set_viewport_area(Rect::new( + /*x*/ 0, /*y*/ 21, /*width*/ 80, /*height*/ 3, + )); + + app.chat_widget.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + let initial_status = match app_event_rx.try_recv() { + Ok(event @ AppEvent::InsertHistoryCell(_)) => event, + other => panic!("expected immediate status card, got {other:?}"), + }; + let request_id = match app_event_rx.try_recv() { + Ok(AppEvent::RefreshThreadUsage { + thread_id: requested_thread_id, + request_id, + }) if requested_thread_id == thread_id => request_id, + other => panic!("expected asynchronous thread usage request, got {other:?}"), + }; + app.handle_event(&mut tui, &mut app_server, initial_status) + .await?; + + Ok((app, app_server, tui, thread_id, request_id)) +} + +fn successful_thread_usage(thread_id: ThreadId, request_id: u64) -> AppEvent { + AppEvent::ThreadUsageLoaded { + thread_id, + request_id, + result: Ok(ThreadUsageOutcome::Available(ThreadUsage { + thread_id: thread_id.to_string(), + estimated_usage_credits_micros: 50_000_000, + estimated_usage_usd_micros: Some(1_820_000), + groups: Vec::new(), + })), + } +} + +fn pending_history_text(tui: &tui::Tui) -> String { + tui.pending_history_lines_for_test() + .into_iter() + .map(|line| { + line.line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[tokio::test] +async fn account_updated_with_backend_only_auth_enables_thread_usage() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.chat_widget.handle_thread_session(test_thread_session( + thread_id, + test_path_buf("/tmp/project"), + )); + while app_event_rx.try_recv().is_ok() {} + let app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated( + AccountUpdatedNotification { + auth_mode: Some(AuthMode::AgentIdentity), + plan_type: Some(PlanType::Business), + }, + ))), + ) + .await; + + assert!(!app.chat_widget.has_chatgpt_account()); + assert!(app.chat_widget.has_codex_backend_auth()); + app.chat_widget.add_status_output( + /*refreshing_rate_limits*/ false, /*request_id*/ None, + ); + assert_matches!(app_event_rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::RefreshThreadUsage { + thread_id: requested_thread_id, + .. + }) if requested_thread_id == thread_id + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn completed_thread_usage_updates_status_without_scrollback_reflow() -> Result<()> { + let (mut app, mut app_server, mut tui, thread_id, request_id) = + app_with_pending_thread_usage().await?; + assert!(!app.transcript_reflow.has_pending_reflow()); + assert!(pending_history_text(&tui).contains("/status")); + + app.handle_event( + &mut tui, + &mut app_server, + successful_thread_usage(thread_id, request_id), + ) + .await?; + + assert!(!app.transcript_reflow.has_pending_reflow()); + assert!( + tui.pending_history_lines_for_test().is_empty(), + "a visible status tail should be replaced directly instead of appending a duplicate card" + ); + let status = app + .transcript_cells + .last() + .expect("status card should remain in terminal history"); + let rendered = lines_to_single_string(&status.display_lines(/*width*/ 90)); + assert!(rendered.contains("50 credits"), "{rendered}"); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn thread_usage_after_intervening_history_appends_refreshed_status() -> Result<()> { + let (mut app, mut app_server, mut tui, thread_id, request_id) = + app_with_pending_thread_usage().await?; + app.insert_history_cell( + &mut tui, + Box::new(PlainHistoryCell::new(vec![ + Line::from("/status"), + Line::from("intervening model output"), + ])), + ); + + app.handle_event( + &mut tui, + &mut app_server, + successful_thread_usage(thread_id, request_id), + ) + .await?; + + let pending = pending_history_text(&tui); + assert!(pending.contains("intervening model output"), "{pending}"); + assert!(pending.contains("50 credits"), "{pending}"); + assert!(!app.transcript_reflow.has_pending_reflow()); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn failed_thread_usage_after_intervening_history_does_not_duplicate_status() -> Result<()> { + let (mut app, mut app_server, mut tui, thread_id, request_id) = + app_with_pending_thread_usage().await?; + app.insert_history_cell( + &mut tui, + Box::new(PlainHistoryCell::new(vec![Line::from( + "intervening model output", + )])), + ); + let pending_before_failure = pending_history_text(&tui); + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ThreadUsageLoaded { + thread_id, + request_id, + result: Err("thread usage request timed out".to_string()), + }, + ) + .await?; + + assert_eq!(pending_history_text(&tui), pending_before_failure); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn thread_usage_finishing_during_overlay_updates_history_after_close() -> Result<()> { + let (mut app, mut app_server, mut tui, thread_id, request_id) = + app_with_pending_thread_usage().await?; + tui.set_alt_screen_enabled(/*enabled*/ false); + app.open_transcript_overlay(&mut tui); + + app.handle_event( + &mut tui, + &mut app_server, + successful_thread_usage(thread_id, request_id), + ) + .await?; + assert!(app.pending_thread_usage_history_refresh); + + app.close_transcript_overlay(&mut tui); + + assert!(!app.pending_thread_usage_history_refresh); + assert!(tui.pending_history_lines_for_test().is_empty()); + assert!( + app.last_rendered_history_tail + .as_ref() + .expect("updated status should remain the rendered history tail") + .lines + .iter() + .any(|line| line.line.to_string().contains("50 credits")) + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn account_change_discards_thread_usage_deferred_while_overlay_is_open() -> Result<()> { + let (mut app, mut app_server, mut tui, thread_id, request_id) = + app_with_pending_thread_usage().await?; + tui.set_alt_screen_enabled(/*enabled*/ false); + app.open_transcript_overlay(&mut tui); + app.handle_event( + &mut tui, + &mut app_server, + successful_thread_usage(thread_id, request_id), + ) + .await?; + assert!(app.pending_thread_usage_history_refresh); + + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated( + AccountUpdatedNotification { + auth_mode: Some(AuthMode::AgentIdentity), + plan_type: Some(PlanType::Business), + }, + ))), + ) + .await; + assert!(!app.pending_thread_usage_history_refresh); + assert!(app.last_thread_usage_status_cell.is_none()); + + app.close_transcript_overlay(&mut tui); + + assert!(!pending_history_text(&tui).contains("50 credits")); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn terminal_reflow_rebases_pending_status_update_to_new_width() -> Result<()> { + let (mut app, mut app_server, mut tui, thread_id, request_id) = + app_with_pending_thread_usage().await?; + let original = app + .last_rendered_history_tail + .as_ref() + .expect("initial rendered status") + .lines + .clone(); + let screen_size = Size::new(/*width*/ 52, /*height*/ 24); + tui.terminal.last_known_screen_size = screen_size; + let viewport = Rect::new( + /*x*/ 0, + /*y*/ 21, + screen_size.width, + /*height*/ 3, + ); + tui.terminal.set_viewport_area(viewport); + + app.reflow_transcript_now(&mut tui, screen_size.into())?; + + let resized = app + .last_rendered_history_tail + .as_ref() + .expect("resized rendered status") + .lines + .clone(); + assert_ne!(original, resized); + // Bazel tests run without a TTY, so model the settled viewport explicitly + // before simulating an asynchronous usage update after resize replay. + tui.terminal.set_viewport_area(viewport); + app.handle_event( + &mut tui, + &mut app_server, + successful_thread_usage(thread_id, request_id), + ) + .await?; + assert!(tui.pending_history_lines_for_test().is_empty()); + assert!(!app.transcript_reflow.has_pending_reflow()); + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/transcript_composer.rs b/codex-rs/tui/src/app/tests/transcript_composer.rs new file mode 100644 index 0000000000000000000000000000000000000000..49aafa269c6e0be3990485241962ac2e0a5ae70b --- /dev/null +++ b/codex-rs/tui/src/app/tests/transcript_composer.rs @@ -0,0 +1,343 @@ +//! Regression coverage for transcript viewer input, prompt selection, and restoration. +//! +//! The default-off feature must leave the existing viewer and its draft intact. +//! Analytics also preserves the composer and stays separate from transcript backtracking. + +use super::*; +use crate::test_support::test_path_display; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String { + buffer + .content() + .chunks(usize::from(buffer.area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} + +async fn press_key( + app: &mut App, + tui: &mut crate::tui::Tui, + app_server: &mut AppServerSession, + code: KeyCode, +) -> Result<()> { + app.handle_tui_event( + tui, + app_server, + TuiEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)), + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn analytics_preserves_the_draft_and_retains_the_view_on_reopen() -> Result<()> { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget + .apply_external_edit("preserved draft".into()); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::OpenAnalytics { view: None }, + ) + .await?; + for code in [ + KeyCode::Char('4'), + KeyCode::Enter, + KeyCode::Down, + KeyCode::Char('r'), + ] { + press_key(&mut app, &mut tui, &mut app_server, code).await?; + assert!(matches!(app.overlay, Some(Overlay::Analytics(_)))); + assert!(!app.backtrack.overlay_preview_active); + } + press_key(&mut app, &mut tui, &mut app_server, KeyCode::Char('q')).await?; + assert!(app.overlay.is_none()); + assert!(app.retained_analytics.is_some()); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::OpenAnalytics { view: None }, + ) + .await?; + assert!(matches!(app.overlay, Some(Overlay::Analytics(_)))); + assert!(app.retained_analytics.is_none()); + press_key(&mut app, &mut tui, &mut app_server, KeyCode::Char('q')).await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "preserved draft" + ); + Ok(()) +} + +#[tokio::test] +async fn analytics_menu_reopen_preserves_navigation_and_explicit_view_selects_summary() -> Result<()> +{ + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let http = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/backend-api/wham/accounts/check")) + .and(wiremock::matchers::header( + "chatgpt-account-id", + "test-account", + )) + .respond_with(wiremock::ResponseTemplate::new(/*s*/ 200).set_body_json( + serde_json::json!({"accounts": [{"id": "test-account", "plan_type": "plus"}]}), + )) + .expect(/*r*/ 3) + .mount(&http) + .await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .respond_with( + wiremock::ResponseTemplate::new(/*s*/ 200) + .set_body_json(serde_json::json!({"stats":{"lifetime_tokens":12345},"data":[]})), + ) + .mount(&http) + .await; + app.config.chatgpt_base_url = format!("{}/backend-api", http.uri()); + app.config.cli_auth_credentials_store_mode = codex_login::AuthCredentialsStoreMode::File; + app_test_support::write_chatgpt_auth( + &app.config.codex_home, + app_test_support::ChatGptAuthFixture::new("test-access-token") + .account_id("test-account") + .chatgpt_account_id("test-account") + .chatgpt_user_id("test-user") + .plan_type("plus"), + codex_login::AuthCredentialsStoreMode::File, + ) + .unwrap(); + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut screens = Vec::new(); + for explicit in [ + None, + None, + Some(crate::analytics::TokenActivityView::Weekly), + ] { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::OpenAnalytics { view: explicit }, + ) + .await?; + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), async { + loop { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + let text = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )); + if screens.is_empty() && text.contains("Lifetime tokens") { + press_key(&mut app, &mut tui, &mut app_server, KeyCode::Char('4')).await?; + continue; + } + if text.contains("Lifetime tokens") + || text.contains("No activity") + || text.contains("No data reported") + { + return Ok::<(), color_eyre::Report>(()); + } + tokio::time::sleep(std::time::Duration::from_millis(/*millis*/ 5)).await; + } + }) + .await??; + let screen = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )); + screens.push(screen); + press_key(&mut app, &mut tui, &mut app_server, KeyCode::Char('q')).await?; + } + assert_eq!(screens[0], screens[1]); + assert!(screens[1].contains("[4 Plugins called]")); + assert!(screens[2].contains("[1 Summary]")); + assert!(screens[2].contains("Weekly")); + let snapshot = screens.join("\n\n"); + let snapshot = snapshot + .lines() + .map(|line| { + if line.starts_with(" [7d] 30d · ") { + " [7d] 30d · [date range]" + } else { + line + } + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!(snapshot); + Ok(()) +} + +#[tokio::test] +async fn analytics_keeps_queued_and_late_startup_history_out_of_the_overlay() -> Result<()> { + for alternate_screen in [true, false] { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_alt_screen_enabled(alternate_screen); + app.insert_history_cell( + &mut tui, + Box::new(PlainHistoryCell::new(vec!["Startup header".into()])), + ); + assert!(!tui.pending_history_lines_for_test().is_empty()); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::OpenAnalytics { view: None }, + ) + .await?; + assert!(tui.pending_history_lines_for_test().is_empty()); + app.insert_history_cell( + &mut tui, + Box::new(PlainHistoryCell::new(vec![ + "Late startup announcement".into(), + ])), + ); + assert!(tui.pending_history_lines_for_test().is_empty()); + let deferred = app.deferred_history_lines.clone(); + assert!(!deferred.is_empty()); + press_key(&mut app, &mut tui, &mut app_server, KeyCode::Char('q')).await?; + assert_eq!(tui.pending_history_lines_for_test(), deferred); + assert_eq!(app.transcript_cells.len(), 2); + } + Ok(()) +} + +#[tokio::test] +async fn transcript_flag_off_preserves_viewer_and_backtracking() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let keymap_config = toml::from_str("[composer]\nsubmit = [\"ctrl-x enter\"]")?; + app.keymap = + crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid composer chord"); + app.chat_widget + .apply_keymap_update(keymap_config, &app.keymap); + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()); + app.chat_widget.handle_thread_session(session); + app.transcript_cells = ["first", "second"] + .map(|message| { + Arc::new(UserHistoryCell { + message: message.into(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + spoken: false, + }) as Arc + }) + .to_vec(); + app.chat_widget + .apply_external_edit("preserved draft".into()); + app.open_transcript_overlay(&mut tui); + for event in [ + TuiEvent::Paste("not composer input".into()), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)), + ] { + app.handle_tui_event(&mut tui, &mut app_server, event) + .await?; + } + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "preserved draft" + ); + let chord_prefix = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(chord_prefix)) + .await?; + assert!(!app.key_chord_matcher.is_pending()); + assert!(!app.backtrack.overlay_preview_active); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 100, /*height*/ 12, + ); + let mut buffer = ratatui::buffer::Buffer::empty(area); + let Some(Overlay::Transcript(overlay)) = &mut app.overlay else { + panic!("viewer closed") + }; + overlay.render(area, &mut buffer); + insta::assert_snapshot!("transcript_flag_off_viewer", buffer_text(&buffer)); + for (key, selected) in [ + (KeyCode::Esc, 1), + (KeyCode::Esc, 0), + (KeyCode::Right, 1), + (KeyCode::Right, 1), + ] { + press_key(&mut app, &mut tui, &mut app_server, key).await?; + assert_eq!(app.backtrack.nth_user_message, selected); + } + press_key(&mut app, &mut tui, &mut app_server, KeyCode::Enter).await?; + assert!(app.overlay.is_none()); + assert!( + std::iter::from_fn(|| app_event_rx.try_recv().ok()).any(|event| matches!( + event, + AppEvent::RevertSessionForPromptEdit { + nth_user_message: 1, + .. + } + )) + ); + Ok(()) +} + +async fn assert_transcript_close_repaints_inline_draft(mut app: App) -> Result<()> { + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.insert_str("EDGE-DRAFT-MUST-SURVIVE"); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + let inline_viewport = tui.terminal.viewport_area; + app.open_transcript_overlay(&mut tui); + tui.enter_alt_screen()?; + app.insert_history_cell( + &mut tui, + Box::new(PlainHistoryCell::new(vec!["arrived in transcript".into()])), + ); + let deferred_history = app.deferred_history_lines.clone(); + assert!(!deferred_history.is_empty()); + app.apply_raw_output_mode(&mut tui, /*enabled*/ true, /*notify*/ false); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + assert_eq!(app.deferred_history_lines, deferred_history); + assert!(app.transcript_reflow.has_pending_reflow()); + + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + ) + .await?; + assert_eq!(tui.terminal.viewport_area, inline_viewport); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw) + .await?; + assert!(!app.transcript_reflow.has_pending_reflow()); + assert_eq!( + app.last_rendered_history_tail + .as_ref() + .expect("inline history reflowed") + .lines, + deferred_history + ); + + insta::assert_snapshot!( + "transcript_close_restores_inline_draft", + buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal + )) + .replace(&test_path_display("/tmp/project"), "/tmp/project") + ); + Ok(()) +} + +#[tokio::test] +async fn transcript_viewer_close_repaints_preserved_inline_draft() -> Result<()> { + assert_transcript_close_repaints_inline_draft(make_test_app().await).await +} diff --git a/codex-rs/tui/src/app/tests/turn_submission.rs b/codex-rs/tui/src/app/tests/turn_submission.rs new file mode 100644 index 0000000000000000000000000000000000000000..6fd095fbb8b1233b2ea86bafd2f482111d490aeb --- /dev/null +++ b/codex-rs/tui/src/app/tests/turn_submission.rs @@ -0,0 +1,466 @@ +use super::*; +use crate::chatwidget::UserMessage; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::ThreadGoalStatus; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn worktree_creation_event_requires_feature() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + app.config.features.disable(Feature::Worktrees)?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + while events.try_recv().is_ok() {} + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::StartManagedWorktree { + mode: crate::app_event::ManagedWorktreeMode::New, + name: None, + }, + ) + .await?; + drain_managed_worktree_start(&mut app, &mut app_server).await; + let message = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 100))) + } + _ => None, + }) + .expect("disabled feature message"); + insta::assert_snapshot!(message, @"■ Enable worktrees in your Codex configuration to create a worktree."); + assert!(!app.config.codex_home.join("worktrees").exists()); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn worktree_creation_rejects_untrusted_source_before_allocation() -> Result<()> { + use crate::app_event::ManagedWorktreeMode; + + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + app.config.features.enable(Feature::Worktrees)?; + app.config.active_project.trust_level = + Some(codex_protocol::config_types::TrustLevel::Untrusted); + let thread_id = ThreadId::new(); + app.primary_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + for mode in [ManagedWorktreeMode::New, ManagedWorktreeMode::Fork] { + if mode == ManagedWorktreeMode::Fork { + app.config.active_project.trust_level = + Some(codex_protocol::config_types::TrustLevel::Trusted); + crate::legacy_core::config::set_project_trust_level( + &app.config.codex_home, + app.config.cwd.as_path(), + codex_protocol::config_types::TrustLevel::Untrusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + } + while events.try_recv().is_ok() {} + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::StartManagedWorktree { mode, name: None }, + ) + .await?; + drain_managed_worktree_start(&mut app, &mut app_server).await; + let message = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 100))) + } + _ => None, + }) + .expect("source trust error"); + insta::allow_duplicates! { + insta::assert_snapshot!(message, @"■ Cannot create a worktree from an explicitly untrusted source."); + } + assert!(!app.config.codex_home.join("worktrees").exists()); + } + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn worktree_creation_rejects_running_agent_before_allocation() -> Result<()> { + let (mut app, mut events, _op_rx) = make_test_app_with_channels().await; + let home = tempfile::tempdir()?; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.features.enable(Feature::Worktrees)?; + app.config.active_project.trust_level = Some(codex_protocol::config_types::TrustLevel::Trusted); + let primary = ThreadId::new(); + app.primary_thread_id = Some(primary); + app.chat_widget + .handle_thread_session_quiet(test_thread_session(primary, app.config.cwd.to_path_buf())); + let child = ThreadId::new(); + app.agent_navigation.upsert( + child, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, + ); + app.agent_navigation.set_running(child, /*is_running*/ true); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + while events.try_recv().is_ok() {} + + app.handle_event( + &mut tui, + &mut server, + AppEvent::StartManagedWorktree { + mode: crate::app_event::ManagedWorktreeMode::New, + name: None, + }, + ) + .await?; + drain_managed_worktree_start(&mut app, &mut server).await; + + assert!(!app.pending_managed_worktree_creation); + assert!(!home.path().join("worktrees").exists()); + let message = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 100))) + } + _ => None, + }) + .expect("running agent message"); + insta::assert_snapshot!(message, @"■ Cannot change: another agent is running."); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn turn_start_failure_is_shown_without_exiting() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000")?; + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + while app_event_rx.try_recv().is_ok() {} + + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("hello")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let op = next_user_turn_op(&mut op_rx); + while let Ok(event) = app_event_rx.try_recv() { + app.handle_event(&mut tui, &mut app_server, event).await?; + drain_managed_worktree_start(&mut app, &mut app_server).await; + } + + let control = app + .handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(op)) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + let error_cell = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .expect("turn/start failure should be added to history"); + let transcript = app + .transcript_cells + .iter() + .map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80))) + .chain(std::iter::once(lines_to_single_string( + &error_cell.display_lines(/*width*/ 80), + ))) + .collect::>() + .join("\n"); + insta::assert_snapshot!(transcript, @r" + › hello + + ■ Failed to start turn: turn/start failed in TUI: turn/start failed: thread not found: 123e4567-e89b-12d3-a456-426614174000 (code -32600) + "); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn misalignment_policy_blocks_queued_turns_and_goal_resumption() -> Result<()> { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let checkout = tempfile::tempdir()?; + app.config.cwd = AbsolutePathBuf::from_absolute_path(checkout.path())?; + for args in [ + vec!["init", "--quiet"], + vec![ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "--allow-empty", + "-m", + "initial", + ], + ] { + assert!( + std::process::Command::new("git") + .args(args) + .current_dir(checkout.path()) + .status()? + .success() + ); + } + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = app_server.start_thread(&app.config).await?; + let thread_id = started.session.thread_id; + let session = started.session.clone(); + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app_server + .thread_goal_set( + thread_id, + Some("Do not restart this stopped goal".to_string()), + Some(ThreadGoalStatus::Blocked), + /*token_budget*/ None, + ) + .await?; + app.chat_widget + .restore_user_message_to_composer("queued before terminal failure".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let queued_turn = next_user_turn_op(&mut op_rx); + let failed_turn = Turn { + error: Some(AppServerTurnError { + misalignment: None, + message: "misalignment policy violation".to_string(), + codex_error_info: Some(CodexErrorInfo::MisalignmentPolicyViolation), + additional_details: None, + }), + ..test_turn("turn-1", TurnStatus::Failed, Vec::new()) + }; + app.enqueue_primary_thread_session(session, vec![failed_turn]) + .await?; + let precaution = render_bottom_popup(&app.chat_widget, /*width*/ 80); + let AppServerRequestId::Integer(next_request_id) = app_server.next_request_id() else { + unreachable!("embedded app-server request IDs are integers"); + }; + + app.maybe_prompt_resume_paused_goal_after_resume(&mut app_server, thread_id) + .await; + for event in [ + AppEvent::CodexOp(queued_turn), + AppEvent::CodexOp(crate::app_command::AppCommand::ExecApproval { + id: "queued-approval".to_string(), + turn_id: None, + decision: codex_app_server_protocol::CommandExecutionApprovalDecision::Accept, + }), + AppEvent::SetThreadGoalStatus { + thread_id, + status: ThreadGoalStatus::Active, + }, + AppEvent::SetThreadGoalDraft { + thread_id, + draft: crate::goal_files::GoalDraft { + objective: "Do not replace this stopped goal".to_string(), + ..Default::default() + }, + mode: crate::app_event::ThreadGoalSetMode::ReplaceExisting, + }, + AppEvent::StartSide { + parent_thread_id: thread_id, + user_message: Some("Do not fork this stopped thread".into()), + }, + AppEvent::ForkCurrentSession { name: None }, + AppEvent::StartManagedWorktree { + mode: crate::app_event::ManagedWorktreeMode::Fork, + name: None, + }, + AppEvent::OpenAgentPicker, + AppEvent::SelectAgentThread(thread_id), + ] { + app.handle_event(&mut tui, &mut app_server, event).await?; + } + + assert!(!app.config.codex_home.join("worktrees").exists()); + assert_eq!( + app_server.next_request_id(), + AppServerRequestId::Integer(next_request_id + 1) + ); + assert_eq!( + render_bottom_popup(&app.chat_widget, /*width*/ 80), + precaution + ); + assert_eq!( + app_server + .thread_goal_get(thread_id) + .await? + .goal + .unwrap() + .status, + ThreadGoalStatus::Blocked + ); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn misalignment_policy_in_parent_stops_active_side_conversation() -> Result<()> { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.primary_thread_id = Some(parent_thread_id); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.chat_widget.handle_thread_session(test_thread_session( + side_thread_id, + app.config.cwd.to_path_buf(), + )); + app.chat_widget + .restore_user_message_to_composer("pending side turn".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + while op_rx.try_recv().is_ok() {} + + app.enqueue_thread_notification( + parent_thread_id, + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + misalignment: None, + message: "misalignment policy violation".to_string(), + codex_error_info: Some(CodexErrorInfo::MisalignmentPolicyViolation), + additional_details: None, + }, + will_retry: false, + thread_id: parent_thread_id.to_string(), + turn_id: "parent-turn".to_string(), + }), + ) + .await?; + + assert!(app.chat_widget.has_misalignment_policy_violation()); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 80) + .contains("Chat stopped as a precaution") + ); + assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt)); + + Ok(()) +} + +#[tokio::test] +async fn unsupported_legacy_permissions_are_shown_without_exiting() -> Result<()> { + let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.chat_widget + .handle_thread_session(test_thread_session(thread_id, app.config.cwd.to_path_buf())); + + let extra_root = codex_utils_absolute_path::AbsolutePathBuf::resolve_path_against_base( + "extra", + app.config.cwd.as_path(), + ); + let permission_profile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { + entries: vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + missing_path_behavior: None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: extra_root.into(), + }, + access: FileSystemAccessMode::Write, + missing_path_behavior: None, + }, + ], + glob_scan_max_depth: None, + }, + }; + app.config + .permissions + .set_permission_profile(permission_profile.clone())?; + app.chat_widget.set_permission_profile_with_active_profile( + permission_profile, + /*active_permission_profile*/ None, + )?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + while app_event_rx.try_recv().is_ok() {} + + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("hello")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let op = next_user_turn_op(&mut op_rx); + app.chat_widget + .restore_user_message_to_composer(UserMessage::from("queued follow-up")); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.chat_widget.has_queued_follow_up_messages()); + while let Ok(event) = app_event_rx.try_recv() { + app.handle_event(&mut tui, &mut app_server, event).await?; + } + + let control = app + .handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(op)) + .await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert!(app.chat_widget.has_queued_follow_up_messages()); + let error_cell = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .expect("unsupported permissions should be added to history"); + insta::assert_snapshot!( + lines_to_single_string(&error_cell.display_lines(/*width*/ 200)), + @"■ Failed to start turn: the selected permission profile cannot be safely represented by the legacy app-server sandbox policy; select a named or legacy-compatible permission profile" + ); + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::SettingsSelectionSettled, + ) + .await?; + assert!(app.chat_widget.has_queued_follow_up_messages()); + + app.config + .permissions + .set_permission_profile(PermissionProfile::read_only())?; + app.chat_widget.set_permission_profile_with_active_profile( + PermissionProfile::read_only(), + /*active_permission_profile*/ None, + )?; + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::SettingsSelectionSettled, + ) + .await?; + assert!(!app.chat_widget.has_queued_follow_up_messages()); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..95d00bbcfc8ea2a82a538a8169ff4f1a11b4146d --- /dev/null +++ b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs @@ -0,0 +1,335 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn user_verification_server_request(thread_id: ThreadId, request_id: i64) -> ServerRequest { + ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(request_id), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-verification".to_string()), + server_name: "deployments".to_string(), + request: McpServerElicitationRequest::UserVerification { + title: "Approve deployment?".to_string(), + description: "Verify the production deployment.".to_string(), + challenge: "AQID".to_string(), + }, + }, + } +} + +async fn next_submitted_thread_op( + app_event_rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> (ThreadId, Op) { + time::timeout(std::time::Duration::from_secs(/*secs*/ 1), async { + loop { + match app_event_rx.recv().await { + Some(AppEvent::SubmitThreadOp { thread_id, op }) => break (thread_id, op), + Some(_) => {} + None => panic!("app event channel closed before submitting an operation"), + } + } + }) + .await + .expect("user-verification operation should be submitted promptly") +} + +#[tokio::test] +async fn inactive_thread_user_verification_replays_queued_request_once() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let primary_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + let thread_id = ThreadId::new(); + let request = user_verification_server_request(thread_id, /*request_id*/ 11); + + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(primary_thread_id)); + app.agent_navigation.upsert( + thread_id, + Some("Verifier".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); + app.enqueue_thread_request(thread_id, request.clone()) + .await?; + + assert!(!app.chat_widget.has_active_view()); + assert_eq!( + app.pending_inactive_thread_requests().await, + vec![(thread_id, request)] + ); + + app.side_threads.remove(&side_thread_id); + app.active_thread_id = Some(primary_thread_id); + app.surface_pending_inactive_thread_interactive_requests() + .await?; + app.surface_pending_inactive_thread_interactive_requests() + .await?; + insta::assert_snapshot!( + "inactive_thread_user_verification_prompt", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!( + next_submitted_thread_op(&mut app_event_rx).await, + ( + thread_id, + Op::ResolveUserVerification { + server_name: "deployments".to_string(), + request_id: AppServerRequestId::Integer(11), + response: crate::app_command::UserVerificationResponse::Cancel, + } + ) + ); + assert!(!app.chat_widget.has_active_view()); + assert!( + !std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::SubmitThreadOp { .. })) + ); + Ok(()) +} + +#[tokio::test] +async fn side_toggle_surfaces_pending_verification_from_an_inactive_thread() -> Result<()> { + Box::pin(async { + let (mut app, _events, _ops) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let primary = app_server.start_thread(&app.config).await?; + let primary_thread_id = primary.session.thread_id; + app.enqueue_primary_thread_session(primary.session, primary.turns) + .await?; + + let side_thread_id = ThreadId::new(); + app.side_threads + .insert(side_thread_id, SideThreadState::new(primary_thread_id)); + app.thread_event_channels.insert( + side_thread_id, + ThreadEventChannel::new_with_session( + /*capacity*/ 4, + test_thread_session(side_thread_id, test_path_buf("/tmp/side")), + Vec::new(), + ), + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) + .await?; + assert_eq!(app.active_thread_id, Some(side_thread_id)); + + let agent_thread_id = ThreadId::new(); + app.agent_navigation.upsert( + agent_thread_id, + Some("Verifier".to_string()), + Some("worker".to_string()), + /*is_closed*/ false, + ); + app.enqueue_thread_request( + agent_thread_id, + user_verification_server_request(agent_thread_id, /*request_id*/ 17), + ) + .await?; + assert!(!app.chat_widget.has_active_view()); + + app.toggle_side_conversation(&mut tui, &mut app_server) + .await?; + assert_eq!(app.active_thread_id, Some(primary_thread_id)); + insta::assert_snapshot!( + "side_toggle_surfaces_pending_user_verification", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + app_server.shutdown().await?; + Ok(()) + }) + .await +} + +#[tokio::test] +async fn discarding_side_cancels_verification_and_ignores_late_proof() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let primary_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + let request = user_verification_server_request(side_thread_id, /*request_id*/ 19); + app.active_thread_id = Some(primary_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(primary_thread_id)); + app.thread_event_channels + .insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4)); + app.pending_app_server_requests + .note_server_request(&request); + let attempt = app + .pending_app_server_requests + .user_verification + .begin("deployments", request.id()) + .expect("pending side-thread verification"); + + app.discard_side_thread_in_background(&mut app_server, side_thread_id) + .await; + assert!(attempt.cancelled.is_cancelled()); + assert!( + !app.pending_app_server_requests + .contains_server_request(&request) + ); + + app.finish_user_verification( + &mut app_server, + side_thread_id, + "deployments".to_string(), + request.id().clone(), + attempt.id, + Ok(codex_app_server_protocol::UserVerificationProof { + credential_id: "discarded-credential".to_string(), + signature: "discarded-signature".to_string(), + }), + ) + .await?; + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::SubmitThreadOp { .. })) + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn inactive_thread_user_verification_preserves_foreground_stream() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let foreground_thread_id = ThreadId::new(); + let thread_id = ThreadId::new(); + app.primary_thread_id = Some(foreground_thread_id); + app.active_thread_id = Some(foreground_thread_id); + app.chat_widget.handle_thread_session(test_thread_session( + foreground_thread_id, + test_path_buf("/tmp/project"), + )); + while app_event_rx.try_recv().is_ok() {} + app.chat_widget.handle_server_notification( + agent_message_delta_notification( + foreground_thread_id, + "turn-foreground", + "message-foreground", + "The foreground answer", + ), + /*replay_kind*/ None, + ); + assert!(app.chat_widget.has_active_agent_stream()); + + app.enqueue_thread_request( + thread_id, + user_verification_server_request(thread_id, /*request_id*/ 13), + ) + .await?; + assert!(app.chat_widget.has_active_view()); + assert!(app.chat_widget.has_active_agent_stream()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + app.chat_widget.handle_server_notification( + agent_message_delta_notification( + foreground_thread_id, + "turn-foreground", + "message-foreground", + " continues.", + ), + /*replay_kind*/ None, + ); + app.chat_widget.handle_server_notification( + ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { + thread_id: foreground_thread_id.to_string(), + turn_id: "turn-foreground".to_string(), + completed_at_ms: 0, + item: ThreadItem::AgentMessage { + id: "message-foreground".to_string(), + text: "The foreground answer continues.".to_string(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + }), + /*replay_kind*/ None, + ); + + let mut approved = Vec::new(); + let mut completed_messages = Vec::new(); + let mut tui = crate::tui::test_support::make_test_tui()?; + while let Ok(event) = app_event_rx.try_recv() { + match event { + AppEvent::UserVerificationApproved { + thread_id, + server_name, + request_id, + } => approved.push((thread_id, server_name, request_id)), + AppEvent::InsertHistoryCell(cell) => app.insert_history_cell(&mut tui, cell), + AppEvent::ConsolidateAgentMessage { + source, + cwd, + inline_visualization_context, + scrollback_reflow, + deferred_history_cell, + } => { + completed_messages.push(source.clone()); + app.handle_consolidate_agent_message( + &mut tui, + source, + cwd, + inline_visualization_context, + scrollback_reflow, + deferred_history_cell, + )?; + app.chat_widget.note_stream_consolidation_completed(); + } + _ => {} + } + } + assert_eq!( + approved, + vec![( + thread_id, + "deployments".to_string(), + AppServerRequestId::Integer(13) + )] + ); + assert_eq!(completed_messages, vec!["The foreground answer continues."]); + assert!(!app.chat_widget.has_active_agent_stream()); + insta::assert_snapshot!( + "inactive_thread_user_verification_foreground_history", + app.render_transcript_lines_for_reflow(/*width*/ 80) + .lines + .iter() + .map(rendered_line_text) + .collect::>() + .join("\n") + ); + Ok(()) +} + +#[tokio::test] +async fn active_thread_user_verification_cancels_through_original_request() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let request = user_verification_server_request(thread_id, /*request_id*/ 12); + + let ServerRequest::McpServerElicitationRequest { request_id, params } = request else { + panic!("elicitation request") + }; + app.chat_widget + .handle_elicitation_request_now(request_id, params); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let (submitted_thread_id, op) = next_submitted_thread_op(&mut app_event_rx).await; + assert_eq!(submitted_thread_id, thread_id); + assert_eq!( + op, + Op::ResolveUserVerification { + server_name: "deployments".to_string(), + request_id: AppServerRequestId::Integer(12), + response: crate::app_command::UserVerificationResponse::Cancel, + } + ); +} diff --git a/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs b/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..6f6e0e1db752c9fe36c852d605e9805c6b97679f --- /dev/null +++ b/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs @@ -0,0 +1,68 @@ +use super::*; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse; +use pretty_assertions::assert_eq; + +#[test] +fn old_local_daemon_worktree_error_suggests_update() -> Result<()> { + let target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, + }, + }; + let missing_method = |code, message: &str| { + Err(TypedRequestError::Server { + method: "thread/backgroundTerminals/list".to_string(), + source: JSONRPCErrorError { + code, + message: message.to_string(), + data: None, + }, + }) + }; + let message = managed_worktree_creation::background_terminals_blocker( + missing_method(-32601, "method not found"), + &target, + ) + .expect("unsupported daemon must block worktree creation"); + let cell = crate::history_cell::new_error_event(message.to_string()); + insta::assert_snapshot!( + lines_to_single_string(&cell.display_lines(/*width*/ 120)), + @"■ The local Codex service cannot check background terminals. Run `codex app-server daemon update`, then restart Codex." + ); + assert_eq!( + managed_worktree_creation::background_terminals_blocker( + missing_method( + -32600, + "Invalid request: unknown variant `thread/backgroundTerminals/list`" + ), + &target, + ), + Some(message), + ); + assert_eq!( + managed_worktree_creation::background_terminals_blocker( + missing_method(-32600, "Invalid request: unknown variant `another/method`"), + &target, + ), + Some("Active background terminals block /cd."), + ); + assert_eq!( + managed_worktree_creation::background_terminals_blocker( + missing_method(-32601, "method not found"), + &AppServerTarget::Embedded, + ), + Some("Active background terminals block /cd."), + ); + assert_eq!( + managed_worktree_creation::background_terminals_blocker( + Ok(ThreadBackgroundTerminalsListResponse { + data: Vec::new(), + next_cursor: None, + }), + &target, + ), + None, + ); + Ok(()) +} diff --git a/codex-rs/tui/src/bottom_pane/async_questions/input.rs b/codex-rs/tui/src/bottom_pane/async_questions/input.rs new file mode 100644 index 0000000000000000000000000000000000000000..6d5571f1fcd0c3f5fde0bd55867d443c0025fd4d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/input.rs @@ -0,0 +1,317 @@ +//! Route question actions through the shared composer, preserving drafts until delivery accepts them. +//! Inline Other reserves Up/Down for choices; j/k retain their Vim behavior. + +use super::*; +use ratatui::layout::Rect; + +impl AsyncQuestions { + pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool { + self.focus_is_notes() && self.composer.handles_key_as_editing(key) + } + + pub(super) fn other_selected(&self) -> bool { + self.has_options() && self.selected_option_index() == Some(self.options().len()) + } + + pub(super) fn other_label(&self) -> String { + self.current_answer() + .filter(|_| !self.other_selected()) + .map(|answer| answer.draft.text.trim()) + .filter(|text| !text.is_empty()) + .map(|text| &text[..text.floor_char_boundary(512)]) + .map(|text| crate::text_formatting::truncate_text(text, /*max_graphemes*/ 128)) + .unwrap_or_else(|| self.other_placeholder().to_string()) + } + + pub(super) fn focus_other(&mut self) { + self.select_option(self.options().len()); + } + + pub(super) fn select_option(&mut self, index: usize) { + self.save_current_draft(); + self.state.pending[self.state.current_idx] + .options_state + .selected_idx = Some(index); + self.sync_composer_placeholder(); + } + + pub(super) fn edit(&mut self, key: KeyEvent) { + if key.kind == KeyEventKind::Repeat && self.keymap.composer.queue.is_pressed(key) { + return; + } + + if self.keymap.composer.submit.is_pressed(key) && !self.composer.is_in_paste_burst() { + self.composer.flush_pending_input(); + } + let before = self.composer.snapshot_draft(); + let (result, _) = self.composer.handle_key_event(key); + let queued = matches!(result, InputResult::Queued { .. }); + if matches!( + result, + InputResult::Submitted { .. } | InputResult::Queued { .. } + ) { + self.composer.restore_draft(before); + self.go_next_or_submit(); + if queued && let Some(QuestionSubmission::Submit(text)) = self.submission.take() { + self.submission = Some(QuestionSubmission::Queue(text)); + } + } + } + + pub(super) fn other_prefix_width(&self, width: u16) -> u16 { + let full = format!("› {}. ", self.options_len()).width() as u16; + if full + 2 <= width { + full + } else { + width.saturating_sub(2).min(2) + } + } + + pub(super) fn other_input_area(&self, options_area: Rect) -> Rect { + let prefix_width = self.other_prefix_width(options_area.width); + let width = options_area.width.saturating_sub(prefix_width); + let height = self + .composer + .inline_input_height(width) + .clamp(1, 8) + .min(options_area.height); + Rect::new( + options_area.x.saturating_add(prefix_width), + options_area.bottom().saturating_sub(height), + width, + height, + ) + } + + pub(super) fn render_inline_options(&self, area: Rect, buf: &mut ratatui::buffer::Buffer) { + use ratatui::text::Line; + use ratatui::widgets::Widget; + + let row_width = area.width.saturating_add(1); + let input = self.other_input_area(area); + let named_area = Rect::new(area.x, area.y, area.width, input.y.saturating_sub(area.y)); + let mut rows = self.option_rows(); + rows.pop(); + let mut state = ScrollState::default(); + while state.scroll_top + 1 < rows.len() + && measure_rows_height(&rows, &state, rows.len(), row_width) > named_area.height + { + state.scroll_top += 1; + } + if measure_rows_height(&rows, &state, rows.len(), row_width) <= named_area.height { + self.visible_options + .set((state.scroll_top, rows.len() - state.scroll_top)); + } + crate::bottom_pane::request_user_input::render::render_rows_bottom_aligned( + named_area, + buf, + &rows, + &state, + rows.len(), + "", + ); + let margin = Rect::new( + area.x, + input.y, + input.x.saturating_sub(area.x), + input.height.min(1), + ); + let prefix = format!("› {}. ", self.options_len()); + Line::from(if usize::from(margin.width) < prefix.width() { + "› ".to_string() + } else { + prefix + }) + .style(crate::style::accent_style()) + .render(margin, buf); + self.composer.render_inline_input(input, buf); + } + pub(crate) fn set_keymap(&mut self, keymap: &RuntimeKeymap) { + self.keymap = keymap.clone(); + self.composer.set_keymap_bindings(keymap); + } + + pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { + self.composer.set_vim_enabled(enabled); + } +} + +impl BottomPaneView for AsyncQuestions { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + if self.focus_is_notes() { + self.composer.keymap_contexts().with(KeymapContext::Chat) + } else { + crate::keymap::KeymapContextSet::new(KeymapContext::List).with(KeymapContext::Chat) + } + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn will_interrupt_turn_on_key_event(&self, key: KeyEvent) -> bool { + !self.handles_key_as_editing(key) && self.keymap.chat.interrupt_turn.is_pressed(key) + } + + fn handle_key_event(&mut self, key: KeyEvent) { + if key.kind == KeyEventKind::Repeat && self.other_selector == Some(key.code) { + return; + } + self.other_selector = None; + if key.kind == KeyEventKind::Release || self.is_complete() { + return; + } + self.snooze_auto_resolution(); + if self.handles_key_as_editing(key) { + self.edit(key); + return; + } + if self.keymap.chat.interrupt_turn.is_pressed(key) { + self.app_event_tx.interrupt(); + return; + } + if key.kind == KeyEventKind::Press && self.keymap.chat.skip_question.is_pressed(key) { + if self.delivery_enabled { + self.accept_answer(); + } + return; + } + if self.keymap.chat.edit_queued_message.is_pressed(key) { + self.navigate(/*forward*/ true); + return; + } + if self.keymap.chat.prompt_stack_back.is_pressed(key) { + self.navigate(/*forward*/ false); + return; + } + if self.focus_is_notes() && self.keymap.composer.submit.is_pressed(key) { + if key.kind != KeyEventKind::Press { + return; + } + let empty = self.composer.current_text_with_pending().trim().is_empty(); + if empty && !self.composer.is_in_paste_burst() { + if !self.other_selected() { + self.go_next_or_submit(); + } + } else { + self.edit(key); + } + return; + } + if self.has_options() { + // Default j/k start Other; explicit printable list remaps keep their meaning. + let printable = matches!(key.code, KeyCode::Char(_)) + && !crate::key_hint::has_ctrl_or_alt(key.modifiers); + let remapped = self.keymap.list.action_for(key).is_some_and(|action| { + self.keymap.list.bindings_for(action) + != RuntimeKeymap::defaults().list.bindings_for(action) + }); + if (!printable || remapped) + && (!self.focus_is_notes() || matches!(key.code, KeyCode::Up | KeyCode::Down)) + { + let count = self.options_len(); + let visible = self.visible_options.get().1.max(1); + let mut state = self.state.pending[self.state.current_idx].options_state; + match self.keymap.list.action_for(key) { + Some(ListAction::MoveUp) => state.move_up_wrap(count), + Some(ListAction::MoveDown) => state.move_down_wrap(count), + Some(ListAction::PageUp) => state.page_up_clamped(count, visible), + Some(ListAction::PageDown) => state.page_down_clamped(count, visible), + Some(ListAction::JumpTop) => state.jump_top(count, visible), + Some(ListAction::JumpBottom) => state.jump_bottom(count, visible), + Some(ListAction::Cancel) => { + self.set_expanded(/*expanded*/ false); + return; + } + Some(ListAction::MoveLeft | ListAction::MoveRight) => return, + Some(ListAction::Accept) | None => {} + } + if !matches!( + self.keymap.list.action_for(key), + Some(ListAction::Accept) | None + ) { + self.select_option(state.selected_idx.unwrap_or(0)); + self.state.pending[self.state.current_idx].options_state = state; + return; + } + } + } + if self.focus_is_notes() { + self.edit(key); + return; + } + if self.keymap.list.action_for(key) == Some(ListAction::Accept) { + if key.kind != KeyEventKind::Press { + return; + } + self.go_next_or_submit(); + } else if let KeyCode::Char(ch) = key.code + && !crate::key_hint::has_ctrl_or_alt(key.modifiers) + { + if let Some(index) = self.option_index_for_digit(ch) { + if key.kind != KeyEventKind::Press { + return; + } + self.select_option(index); + if self.other_selected() { + self.other_selector = Some(key.code); + } else { + self.go_next_or_submit(); + } + } else { + self.focus_other(); + self.composer.resume_text_entry(); + self.edit(key); + } + } + } + + fn terminal_title_requires_action(&self) -> bool { + !self.is_complete() + } + fn is_complete(&self) -> bool { + self.unanswered_count() == 0 + } + fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.composer.cancel_vim_search() || self.composer.cancel_history_search() { + return CancellationEvent::Handled; + } + if self.focus_is_notes() && self.composer.clear_for_ctrl_c().is_some() { + self.save_current_draft(); + self.sync_composer_placeholder(); + } else { + return CancellationEvent::NotHandled; + } + CancellationEvent::Handled + } + fn handle_paste(&mut self, text: String) -> bool { + if self.is_complete() || text.is_empty() { + return false; + } + self.snooze_auto_resolution(); + if !self.focus_is_notes() { + self.focus_other(); + } + self.composer.flush_pending_input(); + self.composer.handle_paste(text) + } + fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() + } + fn next_frame_delay(&self) -> Option { + self.timer_remaining(Instant::now()) + .map(|remaining| { + if remaining > Duration::from_secs(20) { + remaining - Duration::from_secs(20) + } else { + remaining.min(Duration::from_secs(1)) + } + }) + .into_iter() + .chain(self.composer.footer_flash_delay()) + .min() + } +} diff --git a/codex-rs/tui/src/bottom_pane/async_questions/layout.rs b/codex-rs/tui/src/bottom_pane/async_questions/layout.rs new file mode 100644 index 0000000000000000000000000000000000000000..6316ec939e2dae6d2b4e044a1393a454a967fbf2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/layout.rs @@ -0,0 +1,116 @@ +//! Allocate question, options, input, and footer rows without intermediate layout plans. +//! Tight layouts reserve freeform editing space; named choices require the complete prompt. + +use ratatui::layout::Rect; + +use super::AsyncQuestions; +use super::DESIRED_SPACERS_BETWEEN_SECTIONS; + +pub(super) struct LayoutSections { + pub(super) progress_area: Rect, + pub(super) question_area: Rect, + pub(super) question_lines: Vec, + pub(super) options_area: Rect, + pub(super) notes_area: Rect, + pub(super) footer_lines: u16, + pub(super) spacer_after_input: u16, +} + +impl AsyncQuestions { + pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { + let has_options = self.has_options(); + let mut question_lines = self.wrapped_question_lines(area.width); + let mut footer_pref = self.footer_lines(area.width, /*option_tip*/ None).len() as u16; + if has_options + && question_lines.len() + + usize::from(self.options_required_height(area.width)) + + usize::from(footer_pref) + + usize::from(self.unanswered_count() > 1) + + usize::from(DESIRED_SPACERS_BETWEEN_SECTIONS) + > usize::from(area.height) + { + footer_pref = self.footer_lines(area.width, Some(self.option_tip())).len() as u16; + } + let progress_pref = u16::from(self.unanswered_count() > 1); + let min_notes = u16::from(!has_options).min(area.height); + let available = area.height.saturating_sub(min_notes); + let question_height = question_lines.len().min(usize::from( + available.saturating_sub(u16::from(self.other_selected())), + )) as u16; + question_lines.truncate(usize::from(question_height)); + let mut remaining = available.saturating_sub(question_height); + let mut options_height = 0; + let mut spacer_before_question = 0; + let spacer_after_question; + let mut spacer_after_options = 0; + let mut spacer_after_input = 0; + let progress_height; + let footer_lines; + let notes_height; + + if has_options { + let full_options_height = self.options_required_height(area.width); + let min_options_height = remaining.min(1); + // Reserve hints and spacers while retaining a row for the selected option. + let reserved = footer_pref + progress_pref + DESIRED_SPACERS_BETWEEN_SECTIONS; + options_height = + full_options_height.min(remaining.saturating_sub(reserved).max(min_options_height)); + remaining -= options_height; + progress_height = progress_pref.min(remaining); + remaining -= progress_height; + + spacer_after_options = u16::from(remaining > footer_pref); + remaining -= spacer_after_options; + footer_lines = footer_pref.min(remaining); + remaining -= footer_lines; + spacer_after_question = u16::from(remaining > 0); + remaining -= spacer_after_question; + options_height += remaining.min(full_options_height.saturating_sub(options_height)); + notes_height = 0; + } else { + // Freeform answers take their preferred input height before hints and progress. + let preferred_notes = self + .composer + .inline_input_height(area.width) + .clamp(1, 8) + .saturating_sub(min_notes) + .min(remaining); + remaining -= preferred_notes; + footer_lines = footer_pref.min(remaining); + remaining -= footer_lines; + progress_height = progress_pref.min(remaining); + remaining -= progress_height; + spacer_after_input = u16::from(remaining > 0); + remaining -= spacer_after_input; + // Drop padding before input or prompt content in a constrained viewport. + spacer_before_question = u16::from(remaining >= 2 && self.unanswered_count() > 1); + spacer_after_question = u16::from(remaining >= 1); + notes_height = min_notes + preferred_notes + remaining + - spacer_before_question + - spacer_after_question; + } + + let mut y = area.y; + let mut take_rows = |height| { + let rows = Rect::new(area.x, y, area.width, height); + y = y.saturating_add(height); + rows + }; + let progress_area = take_rows(progress_height); + take_rows(spacer_before_question); + let question_area = take_rows(question_height); + take_rows(spacer_after_question); + let options_area = take_rows(options_height); + take_rows(spacer_after_options); + let notes_area = take_rows(notes_height); + LayoutSections { + progress_area, + question_area, + question_lines, + options_area, + notes_area, + footer_lines, + spacer_after_input, + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/async_questions/mod.rs b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..0de055ff454d5628dd987fd7fb0df99c2acb8563 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/mod.rs @@ -0,0 +1,262 @@ +//! Inline editing for asynchronous questions. Legacy request_user_input keeps its own overlay. +//! Only locally accepted submissions remove questions; arrival and expiry never steal focus. + +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::ChatComposerConfig; +use crate::bottom_pane::InputResult; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::chat_composer::ComposerDraft; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::selection_popup_common::GenericDisplayRow; +use crate::bottom_pane::selection_popup_common::measure_rows_height; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::KeymapContext; +use crate::keymap::ListAction; +use crate::keymap::RuntimeKeymap; +use codex_protocol::items::AsyncUserInputQuestion; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use std::collections::HashSet; +use std::time::Duration; +use std::time::Instant; +use unicode_width::UnicodeWidthStr; + +mod input; +mod layout; +mod render; +mod state; + +const OTHER_OPTION_LABEL: &str = "Other"; +pub(super) const TIP_SEPARATOR: &str = " "; +pub(super) const DESIRED_SPACERS_BETWEEN_SECTIONS: u16 = 2; + +#[derive(Debug, Clone, PartialEq)] +struct PendingQuestion { + message_id: String, + question: AsyncUserInputQuestion, + options_state: ScrollState, + draft: ComposerDraft, + expires_at: Option, +} + +/// Locally retained async questions; replayed message IDs cannot resurrect handled answers. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct QuestionState { + pending: Vec, + current_idx: usize, + expanded: bool, + seen_ids: HashSet, +} + +pub(crate) enum QuestionSubmission { + Submit(String), + Queue(String), +} + +pub(crate) struct AsyncQuestions { + app_event_tx: AppEventSender, + state: QuestionState, + pub(crate) expanded: bool, + pub(crate) has_queued_messages: bool, + pub(crate) delivery_enabled: bool, + pub(crate) submission: Option, + visible_options: std::cell::Cell<(usize, usize)>, + pub(crate) next_hint: Option, + keymap: RuntimeKeymap, + // Ignore autorepeat from the number key that opened Other. + other_selector: Option, + pub(super) composer: ChatComposer, +} + +impl AsyncQuestions { + pub(crate) fn new( + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + keymap: RuntimeKeymap, + ) -> Self { + let mut composer = ChatComposer::new_with_config( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + "Type your answer".into(), + disable_paste_burst, + ChatComposerConfig { + reset_vim_on_submission: false, + ..ChatComposerConfig::plain_text() + }, + ); + composer.set_keymap_bindings(&keymap); + composer.set_footer_hint_override(Some(Vec::new())); + Self { + app_event_tx, + state: QuestionState::default(), + expanded: false, + has_queued_messages: false, + delivery_enabled: true, + submission: None, + visible_options: std::cell::Cell::new((0, 0)), + next_hint: None, + keymap, + other_selector: None, + composer, + } + } + + fn current_question(&self) -> Option<&AsyncUserInputQuestion> { + self.current_answer().map(|answer| &answer.question) + } + + fn current_answer_mut(&mut self) -> Option<&mut PendingQuestion> { + self.state.pending.get_mut(self.state.current_idx) + } + + fn current_answer(&self) -> Option<&PendingQuestion> { + self.state.pending.get(self.state.current_idx) + } + + pub(super) fn progress_prefix_text(&self) -> String { + let current = self.state.current_idx + 1; + let total = self.unanswered_count(); + format!("{current} of {total}") + } + + fn options(&self) -> &[String] { + self.current_question() + .and_then(|q| q.options.as_deref()) + .unwrap_or_default() + } + + fn has_options(&self) -> bool { + !self.options().is_empty() + } + + fn options_len(&self) -> usize { + self.options().len() + usize::from(self.has_options()) + } + + fn option_index_for_digit(&self, ch: char) -> Option { + let idx = ch.to_digit(10)?.checked_sub(1)? as usize; + (idx < self.options_len()).then_some(idx) + } + + fn selected_option_index(&self) -> Option { + self.current_answer() + .and_then(|answer| answer.options_state.selected_idx) + } + + pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec { + self.current_question() + .map(|q| { + textwrap::wrap(&q.title, width.max(1) as usize) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + }) + .unwrap_or_default() + } + + fn focus_is_notes(&self) -> bool { + !self.has_options() || self.other_selected() + } + + pub(super) fn option_rows(&self) -> Vec { + if !self.has_options() { + return Vec::new(); + }; + let other = self.other_label(); + self.options() + .iter() + .chain(std::iter::once(&other)) + .enumerate() + .map(|(index, label)| { + let prefix = if self.selected_option_index() == Some(index) { + '›' + } else { + ' ' + }; + let number = index + 1; + let prefix = format!("{prefix} {number}. "); + GenericDisplayRow { + name: format!("{prefix}{label}"), + wrap_indent: Some(prefix.width()), + ..Default::default() + } + }) + .collect() + } + + pub(super) fn options_required_height(&self, width: u16) -> u16 { + if !self.has_options() { + return 0; + } + let row_width = width.saturating_add(1); + let rows = self.option_rows(); + if self.other_selected() { + let prefix = self.other_prefix_width(width); + measure_rows_height( + &rows[..rows.len() - 1], + &ScrollState::default(), + rows.len(), + row_width, + ) + self + .composer + .inline_input_height(width.saturating_sub(prefix).max(1)) + .clamp(1, 8) + } else { + measure_rows_height(&rows, &ScrollState::default(), rows.len(), row_width) + } + } + + fn save_current_draft(&mut self) { + self.composer.flush_pending_input(); + if self.focus_is_notes() { + let draft = self.composer.snapshot_draft(); + if let Some(answer) = self.current_answer_mut() { + answer.draft = draft; + } + } + } + + fn restore_current_draft(&mut self) { + self.sync_composer_placeholder(); + let draft = self + .current_answer() + .map(|answer| answer.draft.clone()) + .unwrap_or_default(); + self.composer.restore_inline_draft(draft); + } + + fn other_placeholder(&self) -> &'static str { + if self + .options() + .iter() + .any(|label| label.eq_ignore_ascii_case("Other")) + { + "Other (write an answer)" + } else { + OTHER_OPTION_LABEL + } + } + + fn sync_composer_placeholder(&mut self) { + let text = if self.other_selected() { + self.other_placeholder() + } else { + "Type your answer" + }; + self.composer.set_placeholder_text(text.to_string()); + } + + pub(crate) fn unanswered_count(&self) -> usize { + self.state.pending.len() + } +} + +#[cfg(test)] +#[path = "state_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/async_questions/render.rs b/codex-rs/tui/src/bottom_pane/async_questions/render.rs new file mode 100644 index 0000000000000000000000000000000000000000..59415de15cbd7d727d7e43b99b17cd597cd61252 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/render.rs @@ -0,0 +1,247 @@ +//! Lay out question text, choices, inline editing, and key hints within the available rows. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::bottom_pane::selection_popup_common::menu_surface_inset; +use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; +use crate::bottom_pane::selection_popup_common::render_menu_surface; +use crate::render::renderable::Renderable; + +use super::AsyncQuestions; +use super::TIP_SEPARATOR; +use crate::bottom_pane::request_user_input::render::render_rows_bottom_aligned; +use crate::bottom_pane::request_user_input::render::truncate_line_word_boundary_with_ellipsis; +use crate::keymap::KeymapContext; + +impl Renderable for AsyncQuestions { + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.composer.cursor_style(area) + } + fn desired_height(&self, width: u16) -> u16 { + let width = menu_surface_inset(Rect::new(/*x*/ 0, /*y*/ 0, width, u16::MAX)).width; + let extra_height = self.options_required_height(width) + + if !self.has_options() { + self.composer.inline_input_height(width).clamp(1, 8) + } else { + 0 + } + + 2 + + u16::from(!self.has_options() && self.unanswered_count() > 1) + + self.footer_lines(width, /*option_tip*/ None).len() as u16 + + u16::from(self.unanswered_count() > 1) + + menu_surface_padding_height(); + u16::try_from(self.wrapped_question_lines(width).len() + usize::from(extra_height)) + .unwrap_or(u16::MAX) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.visible_options.set((0, 0)); + ratatui::widgets::Clear.render(area, buf); + let content_area = render_menu_surface(area, buf); + if content_area.is_empty() { + return; + } + let sections = self.layout_sections(content_area); + Paragraph::new(self.progress_prefix_text().dim()).render(sections.progress_area, buf); + Paragraph::new(sections.question_lines.join("\n")) + .style(crate::style::accent_style()) + .bold() + .render(sections.question_area, buf); + + // The shared measurer reserves a scrollbar column; this renderer uses the full width. + let option_rows = self.option_rows(); + + if self.other_selected() { + self.render_inline_options(sections.options_area, buf); + } else if self.has_options() && sections.options_area.height > 0 { + let mut options_state = self.state.pending[self.state.current_idx].options_state; + let selected = options_state.selected_idx.unwrap_or(0); + let heights: Vec<_> = option_rows + .iter() + .map(|row| { + super::measure_rows_height( + std::slice::from_ref(row), + &super::ScrollState::default(), + /*max_results*/ 1, + sections.options_area.width.saturating_add(1), + ) + }) + .collect(); + let available = sections.options_area.height; + if heights.iter().sum::() <= available { + options_state.scroll_top = 0; + } + let mut first = options_state.scroll_top.min(selected); + while first < selected && heights[first..=selected].iter().sum::() > available { + first += 1; + } + let mut used = 0; + let visible = heights[first..] + .iter() + .take_while(|&&height| { + used += height; + used <= available + }) + .count(); + options_state.scroll_top = first; + self.visible_options.set((first, visible)); + render_rows_bottom_aligned( + sections.options_area, + buf, + &option_rows, + &options_state, + option_rows.len().max(1), + "No options", + ); + } + + if !self.has_options() { + self.composer.render_inline_input(sections.notes_area, buf); + } + + let footer_area = Rect::new( + content_area.x, + sections.notes_area.bottom() + sections.spacer_after_input, + content_area.width, + sections.footer_lines, + ); + let input_bottom = if self.other_selected() { + sections.options_area.bottom() + } else { + sections.notes_area.bottom() + }; + if self.focus_is_notes() + && footer_area.y > input_bottom + && let Some(mode) = self.composer.vim_mode_indicator_span() + { + Line::from(mode).right_aligned().render( + Rect::new( + content_area.x, + footer_area.y - 1, + content_area.width, + /*height*/ 1, + ), + buf, + ); + } + let option_tip = (self.has_options() + && sections.options_area.height > 0 + && self.options_required_height(content_area.width) > sections.options_area.height) + .then(|| self.option_tip()); + let lines = self + .footer_lines(footer_area.width, option_tip) + .into_iter() + .map(|line| truncate_line_word_boundary_with_ellipsis(line, footer_area.width as usize)) + .collect::>(); + Paragraph::new(lines).render(footer_area, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if !self.focus_is_notes() { + return None; + } + let content_area = menu_surface_inset(area); + if content_area.width == 0 || content_area.height == 0 { + return None; + } + let sections = self.layout_sections(content_area); + let input_area = if self.other_selected() { + self.other_input_area(sections.options_area) + } else { + sections.notes_area + }; + if input_area.width == 0 || input_area.height == 0 { + return None; + } + self.composer.inline_cursor_pos(input_area) + } +} + +impl AsyncQuestions { + pub(super) fn footer_lines( + &self, + width: u16, + option_tip: Option>, + ) -> Vec> { + if !self.focus_is_notes() + && let Some(flash) = self.composer.inline_flash() + { + return vec![flash]; + } + let mut tips = Vec::new(); + let chat_hint = |action| self.keymap.primary_hint(KeymapContext::Chat, action); + let (context, action) = if self.focus_is_notes() { + (KeymapContext::Composer, "submit") + } else { + (KeymapContext::List, "accept") + }; + // Existing cross-context keymaps keep chat priority in questions. + if let Some(key) = self.keymap.primary_hint(context, action) + && (self.focus_is_notes() + || !matches!(key, crate::key_hint::ShortcutHint::Single(binding) + if self.keymap.chat.interrupt_turn.contains(&binding) + || self.keymap.chat.edit_queued_message.contains(&binding))) + { + tips.push( + Span::styled( + format!("{} submit", key.display_label()), + crate::style::accent_style(), + ) + .bold(), + ); + } + if let Some(key) = chat_hint("skip_question") { + tips.push(format!("{} skip", key.display_label()).dim()); + } + tips.extend(option_tip); + if let Some(key) = chat_hint("prompt_stack_back") { + let label = if self.state.current_idx > 0 { + "prev question" + } else { + "main prompt" + }; + tips.push(format!("{} {label}", key.display_label()).dim()); + } + let next = if self.state.current_idx + 1 < self.state.pending.len() { + Some("next question") + } else if self.has_queued_messages { + Some("queued messages") + } else { + None + }; + if let Some(label) = next + && let Some(key) = self.next_hint + { + tips.push(format!("{} {label}", key.display_label()).dim()); + } + let mut lines = Vec::new(); + let mut line = Line::default(); + for tip in tips { + if !line.spans.is_empty() { + if line.width() + TIP_SEPARATOR.len() + tip.width() > usize::from(width) { + lines.push(std::mem::take(&mut line)); + } else { + line.spans.push(TIP_SEPARATOR.into()); + } + } + line.spans.push(tip); + } + lines.push(line); + lines + } + + pub(super) fn option_tip(&self) -> Span<'static> { + format!( + "option {}/{}", + self.selected_option_index().unwrap_or(0) + 1, + self.options_len() + ) + .dim() + } +} diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__long_prompt_active_input.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__long_prompt_active_input.snap new file mode 100644 index 0000000000000000000000000000000000000000..045d5532da84d20cc934077768cdb39a2b7c5d6f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__long_prompt_active_input.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: text +--- + + A lengthy prompt. A lengthy prompt. + A lengthy prompt. A lengthy prompt. + A lengthy prompt. A lengthy prompt. + A lengthy prompt. A lengthy prompt. + A lengthy prompt. A lengthy prompt. + visible answer diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_capped_other.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_capped_other.snap new file mode 100644 index 0000000000000000000000000000000000000000..6e0a26e293649981497d0f27a963154db5f44f00 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_capped_other.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + Second + + 1. A suggested answer that is long enough to + wrap across multiple rows + › 2. long draft + long draft + long draft + long draft + long draft + long draft + long draft + + + enter submit ctrl + ] skip + ⌥ + ↓ main prompt diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_clipped_choice_rejected.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_clipped_choice_rejected.snap new file mode 100644 index 0000000000000000000000000000000000000000..662c13a4ca2f9f046cb535796e95b1d832c6388a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_clipped_choice_rejected.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: "buffer_text(&render_editor(&editor, 50, 8))" +--- + + Second + + › 2. A suggested answer that is long enough to + wrap across multiple rows + + Expand terminal to read the entire option diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_clipped_prompt.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_clipped_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..eb8920a91f73fc1b4f4ebe54e868981243800955 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_clipped_prompt.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&clipped) +--- + + Long question Long question Long question diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_inline_history_search.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_inline_history_search.snap new file mode 100644 index 0000000000000000000000000000000000000000..58e4bce6289e5c5a458b432d1dcae1a137ff215e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_inline_history_search.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + 2 of 2 + Second + + 11. Option 11 + 12. Option 12 + › 13. earlier answer + reverse-i-search: e enter accept · esc + + enter submit ctrl + ] skip option 13/13 + ⌥ + ↓ prev question diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_named_other.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_named_other.snap new file mode 100644 index 0000000000000000000000000000000000000000..bceeebde786e402f0ff59ffa67359bbdb6cf0219 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_named_other.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + 2 of 2 + Second + + › 1. Other + 2. Other (write an answer) + + enter submit ctrl + ] skip + ⌥ + ↓ prev question diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_narrow_other.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_narrow_other.snap new file mode 100644 index 0000000000000000000000000000000000000000..ea22bbc560ef1a1800f3a81646feeb842304af03 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_narrow_other.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + 2 of + Secon + d + › x + ente… + ctrl… diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_remapped_choice_footer.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_remapped_choice_footer.snap new file mode 100644 index 0000000000000000000000000000000000000000..f4a07f722af69185e72077b4440a055467023e07 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_remapped_choice_footer.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + 2 of 2 + Second + + › 1. Option 1 is long enough to wrap across + rows + 2. Option 2 is long enough to wrap across + rows + + f9 submit ctrl + ] skip option 1/13 + ⌥ + ↓ prev question diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_named_option.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_named_option.snap new file mode 100644 index 0000000000000000000000000000000000000000..ee4c3d579d45a2687d17dcec33c71f49d2600c4e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_named_option.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + Second + + › 1. A suggested answer that is + long enough to wrap across + multiple rows + 2. Other + + enter submit ctrl + ] skip + ⌥ + ↓ main prompt diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_other.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_other.snap new file mode 100644 index 0000000000000000000000000000000000000000..423d83438e7837f85ab1956c6b6d2fb97564108f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_other.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + Second + + 1. A suggested answer that is + long enough to wrap across + multiple rows + › 2. An alternative answer that + wraps across several option + rows + + enter submit ctrl + ] skip + ⌥ + ↓ main prompt diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_selected_option.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_selected_option.snap new file mode 100644 index 0000000000000000000000000000000000000000..5b1c3c36098e00970597ff1f6c08e8528f75f6df --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__question_wrapped_selected_option.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&clipped) +--- + + Second + + › 2. A suggested answer that is + long enough to wrap across + multiple rows + + enter submit ctrl + ] skip + option 2/3 ⌥ + ↓ main prompt diff --git a/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__selected_other_placeholder.snap b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__selected_other_placeholder.snap new file mode 100644 index 0000000000000000000000000000000000000000..324419be1e719e4a462e29dcfd8f52d438a26060 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/snapshots/codex_tui__bottom_pane__async_questions__tests__selected_other_placeholder.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/async_questions/state_tests.rs +expression: buffer_text(&buffer) +--- + + Long question Long question Long question + Long question Long question Long question + Long question Long question Long question + Long question Long question Long question + Long question Long question Long question + Long question Long question Long question + Long question Long question Long question + › 2. Other diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state.rs b/codex-rs/tui/src/bottom_pane/async_questions/state.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a4a82effa7335cad87e5584153a4f696fc97514 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/state.rs @@ -0,0 +1,184 @@ +//! Pending async questions are local drafts; handling one removes it immediately. +//! Message IDs survive removal so replay cannot reopen an answered or skipped question. + +use super::*; +use codex_context_fragments::AnsweredQuestion; +use codex_context_fragments::ContextualUserFragment; + +impl AsyncQuestions { + pub(crate) fn append(&mut self, message_id: &str, questions: &[AsyncUserInputQuestion]) { + if questions.is_empty() || !self.state.seen_ids.insert(message_id.to_string()) { + return; + } + let was_empty = self.state.pending.is_empty(); + let expires_at = (!self.expanded).then(|| Instant::now() + Duration::from_secs(30)); + self.state.pending.extend(questions.iter().map(|question| { + // Bound work before cloning or wrapping model-authored suggestions. + let question = AsyncUserInputQuestion { + title: question.title.clone(), + options: question.options.as_ref().map(|options| { + options + .iter() + .take(32) + .filter(|label| label.len() <= 512) + .cloned() + .collect() + }), + }; + let has_options = question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()); + let mut options_state = ScrollState::new(); + options_state.selected_idx = has_options.then_some(0); + PendingQuestion { + message_id: message_id.into(), + question, + options_state, + draft: ComposerDraft::default(), + expires_at, + } + })); + if was_empty { + self.state.current_idx = 0; + self.restore_current_draft(); + } + } + + pub(crate) fn set_expanded(&mut self, expanded: bool) { + self.save_current_draft(); + self.expanded = expanded && !self.state.pending.is_empty(); + if self.expanded { + self.snooze_auto_resolution(); + } + } + + pub(crate) fn navigate(&mut self, forward: bool) -> bool { + let next = if forward { + self.state.current_idx.checked_add(1) + } else { + self.state.current_idx.checked_sub(1) + }; + let Some(next) = next.filter(|&index| index < self.state.pending.len()) else { + return false; + }; + self.save_current_draft(); + self.visible_options.set((0, 0)); + self.state.current_idx = next; + self.restore_current_draft(); + true + } + + pub(super) fn snooze_auto_resolution(&mut self) { + for question in &mut self.state.pending { + question.expires_at = None; + } + } + + pub(super) fn timer_remaining(&self, now: Instant) -> Option { + self.state + .pending + .iter() + .filter_map(|question| question.expires_at?.checked_duration_since(now)) + .filter(|remaining| !remaining.is_zero()) + .min() + } + + pub(crate) fn countdown(&self, now: Instant) -> Option { + self.timer_remaining(now) + .filter(|remaining| *remaining <= Duration::from_secs(20)) + .map(|remaining| format!("{}s", remaining.as_secs_f64().ceil() as u64)) + } + + pub(super) fn go_next_or_submit(&mut self) { + self.save_current_draft(); + if !self.delivery_enabled { + return; + } + let Some(answer) = self.current_answer() else { + return; + }; + let selected = answer + .options_state + .selected_idx + .and_then(|index| answer.question.options.as_ref()?.get(index)) + .map(String::as_str) + .unwrap_or_default(); + // Only a fully displayed model-authored option may become user authorization. + let (first, count) = self.visible_options.get(); + let index = self.selected_option_index().unwrap_or(0); + if !self.focus_is_notes() && !(first..first + count).contains(&index) { + self.composer.show_footer_flash( + "Expand terminal to read the entire option".into(), + Duration::from_secs(5), + ); + return; + } + let text = if self.focus_is_notes() { + answer.draft.text_with_pending() + } else { + selected.to_string() + }; + let text = text.trim(); + let framing = AnsweredQuestion::new(&answer.question.title).render(); + let limit = codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS - framing.chars().count(); + if text.chars().count() > limit { + self.composer.show_footer_flash( + format!("Answer too long; limit {limit} characters").into(), + Duration::from_secs(5), + ); + } else if !text.is_empty() { + self.submission = Some(QuestionSubmission::Submit(format!("{framing}{text}"))); + } + } + + pub(crate) fn accept_answer(&mut self) { + if self.state.pending.is_empty() { + return; + } + self.composer.flush_pending_input(); + self.visible_options.set((0, 0)); + self.state.pending.remove(self.state.current_idx); + if self.state.current_idx >= self.state.pending.len() { + self.state.current_idx = 0; + } + self.expanded &= !self.state.pending.is_empty(); + self.restore_current_draft(); + self.composer.reset_vim_mode(); + } + + pub(crate) fn clear_pending(&mut self) { + // Keep seen_ids so replay cannot restore questions from the previous prompt. + self.state.pending.clear(); + self.state.current_idx = 0; + self.expanded = false; + self.submission = None; + self.visible_options.set((0, 0)); + self.restore_current_draft(); + self.composer.reset_vim_mode(); + } + + pub(crate) fn capture(&mut self) -> QuestionState { + self.composer.cancel_history_search(); + self.save_current_draft(); + self.state.expanded = self.expanded; + self.state.clone() + } + + pub(crate) fn restore(&mut self, saved: QuestionState) { + self.visible_options.set((0, 0)); + let incoming = std::mem::replace(&mut self.state, saved); + self.state.pending.extend( + incoming + .pending + .into_iter() + .filter(|question| !self.state.seen_ids.contains(&question.message_id)), + ); + self.state.seen_ids.extend(incoming.seen_ids); + self.expanded = self.state.expanded && !self.state.pending.is_empty(); + if self.expanded { + self.snooze_auto_resolution(); + } + self.restore_current_draft(); + } +} diff --git a/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..d9929d89e3884e8e6b7ac8738a473a9f27d3c5d5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/async_questions/state_tests.rs @@ -0,0 +1,394 @@ +//! Exercise question drafts, selection, and the constrained terminal viewport. +use super::*; +use crate::render::renderable::Renderable; +use crossterm::event::KeyCode; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Modifier; + +fn editor() -> AsyncQuestions { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mut editor = AsyncQuestions::new( + AppEventSender::new(tx), + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ true, + /*disable_paste_burst*/ true, + RuntimeKeymap::defaults(), + ); + editor.next_hint = Some(crate::key_hint::alt(KeyCode::Up).into()); + editor.append( + "message", + &[ + question("First", /*options*/ None), + question("Second", Some(vec!["Named".into()])), + ], + ); + editor +} + +#[test] +fn replay_preserves_drafts_and_does_not_reopen_handled_questions() { + let mut original = editor(); + original.set_expanded(/*expanded*/ true); + original.accept_answer(); + original.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + let saved = original.capture(); + for replay_first in [false, true] { + let mut restored = editor(); + if !replay_first { + restored.state = QuestionState::default(); + } + restored.restore(saved.clone()); + restored.append("message", &[question("First", /*options*/ None)]); + assert_eq!(restored.capture(), saved); + restored.accept_answer(); + restored.append("message", &[question("First", /*options*/ None)]); + assert_eq!(restored.unanswered_count(), 0); + } +} + +#[test] +fn arrival_preserves_vim_undo_and_navigation_flushes_buffered_input() { + let mut editor = editor(); + editor.set_expanded(/*expanded*/ true); + editor.set_vim_enabled(/*enabled*/ true); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('i'))); + editor.handle_paste("draft".into()); + editor.handle_key_event(KeyEvent::from(KeyCode::Esc)); + editor.append("new", &[question("New", /*options*/ None)]); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('u'))); + assert_eq!(editor.composer.current_text(), ""); + editor.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(editor.composer.current_text(), "draft"); + editor.set_vim_enabled(/*enabled*/ false); + editor.composer.set_disable_paste_burst(/*disabled*/ false); + editor.composer.move_cursor_to_end(); + for ch in "buffered".chars() { + editor.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert!(editor.composer.is_in_paste_burst()); + editor.navigate(/*forward*/ true); + editor.navigate(/*forward*/ false); + assert_eq!(editor.composer.current_text_with_pending(), "draftbuffered"); + for ch in "skipped".chars() { + editor.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); + } + assert!(editor.composer.is_in_paste_burst()); + editor.handle_key_event(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL)); + editor.composer.flush_pending_input(); + assert_eq!( + ( + editor.current_question().unwrap().title.as_str(), + editor.composer.current_text() + ), + ("Second", String::new()) + ); +} + +#[test] +fn long_prompt_keeps_the_active_input_visible() { + let mut editor = editor(); + editor.state.pending[0].question.title = "A lengthy prompt. ".repeat(30); + editor.handle_paste("visible answer".into()); + let buffer = render_editor(&editor, /*width*/ 40, /*height*/ 8); + let text = buffer_text(&buffer); + assert!(text.contains("visible answer")); + insta::assert_snapshot!("long_prompt_active_input", text); +} + +#[test] +fn selected_other_renders_as_a_dim_placeholder() { + let mut editor = editor(); + editor.navigate(/*forward*/ true); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + let mut repeat = KeyEvent::from(KeyCode::Char('2')); + repeat.kind = KeyEventKind::Repeat; + editor.handle_key_event(repeat); + assert!(editor.composer.is_empty()); + editor.handle_paste("saved".into()); + editor.handle_key_event(KeyEvent::from(KeyCode::Up)); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + editor.handle_key_event(repeat); + assert_eq!(editor.composer.current_text(), "saved"); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + editor.handle_key_event(repeat); + assert_eq!(editor.composer.current_text(), "saved22"); + editor.on_ctrl_c(); + editor.state.pending[1].question.title = "Long question ".repeat(40); + let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 10); + let area = buffer.area; + assert!(editor.cursor_pos(area).is_some()); + let input = editor.other_input_area( + editor + .layout_sections(crate::bottom_pane::selection_popup_common::menu_surface_inset(area)) + .options_area, + ); + for offset in 0..5 { + assert_eq!(buffer[(input.x + offset, input.y)].modifier, Modifier::DIM); + } + insta::assert_snapshot!("selected_other_placeholder", buffer_text(&buffer)); + editor.state.pending[1].question.title = "Second".into(); + render_editor(&editor, /*width*/ 50, /*height*/ 10); + editor.set_vim_enabled(/*enabled*/ true); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('i'))); + editor.handle_paste("draft".into()); + editor.handle_key_event(KeyEvent::from(KeyCode::Esc)); + editor.handle_key_event(KeyEvent::from(KeyCode::Up)); + assert_eq!(editor.selected_option_index(), Some(0)); + editor.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!(editor.submission.take().is_some()); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); + editor.handle_key_event(KeyEvent::from(KeyCode::Esc)); + for _ in 0..2 { + editor.handle_key_event(KeyEvent::from(KeyCode::Char('u'))); + } + assert_eq!(editor.composer.current_text(), ""); +} + +fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String { + (buffer.area.y..buffer.area.bottom()) + .map(|y| { + (buffer.area.x..buffer.area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n") +} + +#[test] +fn question_choice_remaps_paging_and_inline_feedback() { + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + let mut editor = editor(); + editor.navigate(/*forward*/ true); + editor.state.pending[1].question.options = Some( + (1..=12) + .map(|i| format!("Option {i} is long enough to wrap across rows")) + .collect(), + ); + let mut config = TuiKeymap::default(); + config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("x".into()))); + config.list.accept = Some(KeybindingsSpec::One(KeybindingSpec("f9".into()))); + config.list.jump_top = Some(KeybindingsSpec::One(KeybindingSpec("7".into()))); + editor.set_keymap(&RuntimeKeymap::from_config(&config).unwrap()); + editor.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_eq!(editor.selected_option_index(), Some(0)); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); + assert_eq!(editor.selected_option_index(), Some(1)); + let mut buffer = render_editor(&editor, /*width*/ 50, /*height*/ 12); + let area = buffer.area; + let visible = editor.visible_options.get().1; + assert_eq!(visible, 2); + editor.handle_key_event(KeyEvent::from(KeyCode::PageDown)); + assert_eq!(editor.selected_option_index(), Some(1 + visible)); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('7'))); + assert_eq!(editor.selected_option_index(), Some(0)); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('7'))); + assert_eq!(editor.selected_option_index(), Some(0)); + editor.render(area, &mut buffer); + insta::assert_snapshot!("question_remapped_choice_footer", buffer_text(&buffer)); +} + +#[test] +fn question_inline_history_search() { + let mut editor = editor(); + editor.navigate(/*forward*/ true); + editor.state.pending[1].question.options = + Some((1..=12).map(|i| format!("Option {i}")).collect()); + editor.select_option(/*index*/ 12); + editor + .composer + .record_replayed_user_message_history(crate::bottom_pane::HistoryEntry::new( + "earlier answer".into(), + )); + editor.handle_paste("original answer".into()); + let saved = editor.capture(); + editor.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + editor.handle_key_event(KeyEvent::from(KeyCode::Char('e'))); + let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 12); + assert!( + buffer + .content + .iter() + .any(|cell| cell.modifier.contains(Modifier::REVERSED)) + ); + insta::assert_snapshot!("question_inline_history_search", buffer_text(&buffer)); + assert_eq!(editor.capture(), saved); +} + +#[test] +fn question_other_remains_visible_at_nine_columns_and_distinguishes_named_other() { + let mut editor = editor(); + editor.navigate(/*forward*/ true); + editor.state.pending[1].question.options = Some(vec!["Other".into()]); + editor.handle_paste("other".into()); + editor.on_ctrl_c(); + editor.handle_key_event(KeyEvent::from(KeyCode::Up)); + let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 12); + insta::assert_snapshot!("question_named_other", buffer_text(&buffer)); + editor.select_option(/*index*/ 1); + editor.handle_paste("x".into()); + let buffer = render_editor(&editor, /*width*/ 9, /*height*/ 8); + let narrow = buffer.area; + assert!(editor.cursor_pos(narrow).is_some()); + assert!(buffer.content.iter().any(|cell| cell.symbol() == "x")); + insta::assert_snapshot!("question_narrow_other", buffer_text(&buffer)); +} + +#[test] +fn question_wrapped_options_and_other_share_the_text_indent() { + let mut editor = editor(); + editor.state.pending.remove(0); + editor.state.pending[0].question.options = Some(vec![ + "A suggested answer that is long enough to wrap across multiple rows".into(), + ]); + editor.restore_current_draft(); + let buffer = render_editor(&editor, /*width*/ 36, /*height*/ 16); + insta::assert_snapshot!("question_wrapped_named_option", buffer_text(&buffer)); + let options = editor.state.pending[0].question.options.as_mut().unwrap(); + options.insert( + 0, + "Another suggested answer that wraps over several rows before the selected option".into(), + ); + editor.select_option(/*index*/ 1); + let clipped = render_editor(&editor, /*width*/ 36, /*height*/ 10); + insta::assert_snapshot!("question_wrapped_selected_option", buffer_text(&clipped)); + render_editor(&editor, /*width*/ 0, /*height*/ 0); + editor.go_next_or_submit(); + assert!(editor.submission.is_none()); + render_editor(&editor, /*width*/ 36, /*height*/ 6); + editor.go_next_or_submit(); + assert!(editor.submission.is_none()); + insta::assert_snapshot!( + "question_clipped_choice_rejected", + buffer_text(&render_editor( + &editor, /*width*/ 50, /*height*/ 8 + )) + ); + render_editor(&editor, /*width*/ 80, /*height*/ 20); + editor.go_next_or_submit(); + assert!(editor.submission.take().is_some()); + editor.append( + "many", + &[question("Bounded", Some(vec!["x".repeat(41); 1000]))], + ); + editor.navigate(/*forward*/ true); + assert_eq!(editor.options().len(), 32); + render_editor(&editor, /*width*/ 50, /*height*/ 10); + let height = editor.desired_height(/*width*/ 50); + assert_eq!(editor.visible_options.get().1, 2); + editor.handle_key_event(KeyEvent::from(KeyCode::End)); + assert_eq!(editor.desired_height(/*width*/ 50), height); + assert_eq!( + editor.current_answer().unwrap().options_state, + ScrollState { + selected_idx: Some(editor.options_len() - 1), + scroll_top: editor.options_len() - editor.visible_options.get().1, + } + ); + editor.handle_key_event(KeyEvent::from(KeyCode::Up)); + render_editor(&editor, /*width*/ 80, /*height*/ 80); + assert_eq!(editor.visible_options.get(), (0, editor.options_len())); + editor.state.pending[editor.state.current_idx] + .question + .title = "Long question ".repeat(3 * 65_536); + assert_eq!(editor.desired_height(/*width*/ 50), u16::MAX); + let clipped = render_editor(&editor, /*width*/ 50, /*height*/ 3); + editor.go_next_or_submit(); + assert!(editor.submission.is_none()); + insta::assert_snapshot!("question_clipped_prompt", buffer_text(&clipped)); + editor.state.pending.last_mut().unwrap().question.title = "Question".into(); + for action in ["move_left", "move_right", "cancel"] { + let config = toml::from_str(&format!("[list]\n{action} = '1'")).unwrap(); + editor.set_keymap(&RuntimeKeymap::from_config(&config).unwrap()); + editor.set_expanded(/*expanded*/ true); + render_editor(&editor, /*width*/ 80, /*height*/ 80); + for modifiers in [KeyModifiers::CONTROL, KeyModifiers::ALT] { + editor.handle_key_event(KeyEvent::new(KeyCode::Char('1'), modifiers)); + assert!(editor.submission.is_none()); + } + editor.handle_key_event(KeyEvent::from(KeyCode::Char('1'))); + assert_eq!(editor.expanded, action != "cancel"); + assert!(editor.submission.is_none()); + } +} + +#[test] +fn question_wrapped_other_shares_the_text_indent() { + let mut editor = editor(); + editor.state.pending.remove(0); + editor.state.pending[0].question.options = Some(vec![ + "A suggested answer that is long enough to wrap across multiple rows".into(), + ]); + editor.select_option(/*index*/ 1); + editor.handle_paste("An alternative answer that wraps across several option rows".into()); + let buffer = render_editor(&editor, /*width*/ 36, /*height*/ 16); + insta::assert_snapshot!("question_wrapped_other", buffer_text(&buffer)); + editor.composer.clear_for_ctrl_c(); + editor.handle_paste(" \n ".into()); + editor.select_option(/*index*/ 0); + assert_eq!(editor.other_label(), "Other"); + editor.select_option(/*index*/ 1); + editor + .composer + .set_text_content("long draft\n".repeat(1000), Vec::new(), Vec::new()); + editor.composer.move_cursor_to_end(); + let buffer = render_editor(&editor, /*width*/ 50, /*height*/ 18); + assert_eq!(editor.cursor_pos(buffer.area), Some((7, 12))); + insta::assert_snapshot!("question_capped_other", buffer_text(&buffer)); + editor.select_option(/*index*/ 0); + assert!(editor.other_label().len() <= 128); + editor.state.pending[0].draft.text = format!("a{}", "\u{301}".repeat(10_000)); + assert!(editor.other_label().len() <= 512); +} + +fn question(title: &str, options: Option>) -> AsyncUserInputQuestion { + AsyncUserInputQuestion { + title: title.into(), + options, + } +} + +fn render_editor(editor: &AsyncQuestions, width: u16, height: u16) -> Buffer { + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buffer = Buffer::empty(area); + editor.render(area, &mut buffer); + buffer +} + +#[test] +fn existing_cross_context_keymaps_load_without_misleading_submit_hints() { + for action in ["interrupt_turn", "edit_queued_message"] { + let config = + toml::from_str(&format!("[chat]\n{action} = 'f12'\n[list]\naccept = 'f12'")).unwrap(); + let keymap = RuntimeKeymap::from_config(&config).unwrap(); + let mut editor = editor(); + editor.navigate(/*forward*/ true); + editor.set_keymap(&keymap); + insta::allow_duplicates! { insta::assert_snapshot!(editor.footer_lines(/*width*/ 100, /*option_tip*/ None)[0].to_string(), @"ctrl + ] skip ⌥ + ↓ prev question"); } + editor.handle_key_event(KeyEvent::from(KeyCode::F(12))); + assert!(editor.submission.is_none()); + } +} + +#[test] +fn question_navigation_resets_history_recall() { + let mut editor = editor(); + for text in ["older", "newer"] { + editor.composer.record_replayed_user_message_history( + crate::bottom_pane::HistoryEntry::new(text.into()), + ); + } + editor.handle_key_event(KeyCode::Up.into()); + assert_eq!(editor.composer.current_text(), "newer"); + editor.navigate(/*forward*/ true); + editor.navigate(/*forward*/ false); + editor.handle_key_event(KeyCode::Up.into()); + assert_eq!(editor.composer.current_text(), "newer"); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..abe8f0de2dd925383050c1e7862994bf94f9dc97 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/agents_navigation_tests.rs @@ -0,0 +1,143 @@ +use super::tests::new_test_composer; +use super::tests::snapshot_composer_state_with_width; +use super::tests::type_chars_humanlike; +use super::*; +use assert_matches::assert_matches; +use pretty_assertions::assert_eq; + +#[test] +fn parent_owned_thread_allows_bare_navigation_commands() { + for (command, expected) in [ + ("/agents", SlashCommand::Agents), + ("/subagents", SlashCommand::MultiAgents), + ("/side", SlashCommand::Side), + ("/btw", SlashCommand::Btw), + ("/diff ", SlashCommand::Diff), + ("/daemon", SlashCommand::Daemon), + ] { + let (mut composer, _rx) = new_test_composer(); + composer.set_parent_owned_thread(); + composer.set_text_content(command.to_string(), Vec::new(), Vec::new()); + + assert_eq!( + composer.handle_submission(/*should_queue*/ false).0, + InputResult::Command(expected) + ); + } +} + +#[test] +fn parent_owned_thread_allows_safe_command_selected_from_prefix() { + let (mut composer, _rx) = new_test_composer(); + composer.set_parent_owned_thread(); + type_chars_humanlike(&mut composer, &['/', 'a', 'g']); + + let result = composer + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .0; + + assert_eq!(result, InputResult::Command(SlashCommand::Agents)); +} + +#[test] +fn left_navigates_only_from_an_empty_prompt() { + let (mut composer, mut events) = new_test_composer(); + composer.set_agents_navigation_enabled(/*enabled*/ true); + composer.insert_str("ab"); + composer.handle_key_event(KeyCode::Left.into()); + composer.insert_str("!"); + assert_eq!(composer.current_text(), "a!b"); + assert!(events.try_recv().is_err()); + + composer.clear_for_ctrl_c(); + for kind in [KeyEventKind::Repeat, KeyEventKind::Release] { + composer.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Left, + KeyModifiers::NONE, + kind, + )); + } + assert!(events.try_recv().is_err()); + composer.handle_key_event(KeyCode::Left.into()); + assert_matches!(events.try_recv(), Ok(AppEvent::OpenAgentsOverview)); +} + +#[test] +fn left_respects_attachments_pastes_and_other_input_surfaces() { + let setups: [fn(&mut ChatComposer); 4] = [ + |composer| composer.config = ChatComposerConfig::plain_text(), + |composer| composer.set_remote_image_urls(vec!["https://example.com/image.png".into()]), + |composer| { + composer.handle_key_event(KeyCode::Char('x').into()); + }, + |composer| { + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + }, + ]; + for setup in setups { + let (mut composer, mut events) = new_test_composer(); + composer.set_agents_navigation_enabled(/*enabled*/ true); + setup(&mut composer); + composer.handle_key_event(KeyCode::Left.into()); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::OpenAgentsOverview)) + ); + } +} + +#[test] +fn agents_navigation_hint_snapshots() { + for name in [ + "agents_navigation_enabled", + "agents_navigation_help", + "agents_navigation_status", + ] { + snapshot_composer_state_with_width( + name, + /*width*/ 80, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_agents_navigation_enabled(/*enabled*/ true); + match name { + "agents_navigation_help" => { + composer.handle_key_event(KeyCode::Char('?').into()); + } + "agents_navigation_status" => { + composer.set_status_line_enabled(/*enabled*/ true); + composer.set_status_line(Some(Line::from("model · project"))); + } + _ => {} + } + }, + ); + } +} + +#[test] +fn left_respects_editor_and_vim_history_remaps() { + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + + for vim in [false, true] { + let (mut composer, mut events) = new_test_composer(); + composer.set_agents_navigation_enabled(/*enabled*/ true); + composer.insert_str("previous"); + composer.handle_key_event(KeyCode::Enter.into()); + let mut config = TuiKeymap::default(); + if vim { + config.vim_normal.move_left = Some(KeybindingsSpec::One(KeybindingSpec("h".into()))); + config.vim_normal.move_up = Some(KeybindingsSpec::One(KeybindingSpec("left".into()))); + } else { + config.editor.move_left = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-b".into()))); + config.editor.move_up = Some(KeybindingsSpec::One(KeybindingSpec("left".into()))); + } + composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap()); + composer.set_vim_enabled(vim); + assert!(composer.footer_props().key_hints.agents.is_none()); + composer.handle_key_event(KeyCode::Left.into()); + assert_eq!(composer.current_text(), "previous"); + assert!(events.try_recv().is_err()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/attachment_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/attachment_state.rs new file mode 100644 index 0000000000000000000000000000000000000000..8744fe8b0ae72671c22a4447c93964e46bed739c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/attachment_state.rs @@ -0,0 +1,251 @@ +//! Attachment bookkeeping for the chat composer, including local image placeholders, +//! remote-image rows, and keyboard selection over those rows. + +use std::collections::HashSet; +use std::path::PathBuf; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::style::Stylize; +use ratatui::text::Line; + +use super::InputResult; +use crate::bottom_pane::LocalImageAttachment; +use crate::bottom_pane::textarea::TextArea; +use codex_protocol::models::local_image_label_text; +use codex_protocol::user_input::TextElement; + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct AttachedImage { + pub(super) placeholder: String, + pub(super) path: PathBuf, +} + +#[derive(Debug, Default)] +pub(super) struct AttachmentState { + pub(super) local_images: Vec, + pub(super) remote_image_urls: Vec, + pub(super) selected_remote_image_index: Option, +} + +impl AttachmentState { + pub(super) fn is_empty(&self) -> bool { + self.local_images.is_empty() && self.remote_image_urls.is_empty() + } + + pub(super) fn local_image_paths(&self) -> Vec { + self.local_images + .iter() + .map(|image| image.path.clone()) + .collect() + } + + pub(super) fn local_images(&self) -> Vec { + self.local_images + .iter() + .map(|image| LocalImageAttachment { + placeholder: image.placeholder.clone(), + path: image.path.clone(), + }) + .collect() + } + + pub(super) fn set_remote_image_urls(&mut self, urls: Vec, textarea: &mut TextArea) { + self.remote_image_urls = urls; + self.selected_remote_image_index = None; + self.relabel_local_images(textarea); + } + + pub(super) fn remote_image_urls(&self) -> Vec { + self.remote_image_urls.clone() + } + + pub(super) fn take_remote_image_urls(&mut self, textarea: &mut TextArea) -> Vec { + let urls = std::mem::take(&mut self.remote_image_urls); + self.selected_remote_image_index = None; + self.relabel_local_images(textarea); + urls + } + + pub(super) fn clear_remote_image_urls(&mut self) { + self.remote_image_urls.clear(); + self.selected_remote_image_index = None; + } + + pub(super) fn reset_local_images( + &mut self, + local_image_paths: Vec, + textarea: &mut TextArea, + ) { + self.local_images.clear(); + self.local_images.extend( + local_image_paths + .into_iter() + .enumerate() + .map(|(index, path)| AttachedImage { + placeholder: local_image_label_text(self.remote_image_urls.len() + index + 1), + path, + }), + ); + self.selected_remote_image_index = None; + self.relabel_local_images(textarea); + } + + pub(super) fn attach_image(&mut self, textarea: &mut TextArea, path: PathBuf) { + let image_number = self.remote_image_urls.len() + self.local_images.len() + 1; + let placeholder = local_image_label_text(image_number); + textarea.insert_element(&placeholder); + self.local_images.push(AttachedImage { placeholder, path }); + } + + pub(super) fn prune_local_images_for_submission( + &mut self, + text: &str, + text_elements: &[TextElement], + ) { + if self.local_images.is_empty() { + return; + } + + let image_placeholders: HashSet<&str> = text_elements + .iter() + .filter_map(|element| element.placeholder(text)) + .collect(); + self.local_images + .retain(|image| image_placeholders.contains(image.placeholder.as_str())); + } + + #[cfg(test)] + pub(super) fn take_recent_submission_images(&mut self) -> Vec { + std::mem::take(&mut self.local_images) + .into_iter() + .map(|image| image.path) + .collect() + } + + pub(super) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + std::mem::take(&mut self.local_images) + .into_iter() + .map(|image| LocalImageAttachment { + placeholder: image.placeholder, + path: image.path, + }) + .collect() + } + + pub(super) fn remote_image_lines(&self) -> Vec> { + self.remote_image_urls + .iter() + .enumerate() + .map(|(index, _)| { + let label = local_image_label_text(index + 1); + if self.selected_remote_image_index == Some(index) { + label.cyan().reversed().into() + } else { + label.cyan().into() + } + }) + .collect() + } + + pub(super) fn clear_remote_image_selection(&mut self) { + self.selected_remote_image_index = None; + } + + pub(super) fn handle_remote_image_selection_key( + &mut self, + key_event: &KeyEvent, + textarea: &mut TextArea, + ) -> Option<(InputResult, bool)> { + if self.remote_image_urls.is_empty() + || key_event.modifiers != KeyModifiers::NONE + || key_event.kind != KeyEventKind::Press + { + return None; + } + + match key_event.code { + KeyCode::Up => { + if let Some(selected) = self.selected_remote_image_index { + self.selected_remote_image_index = Some(selected.saturating_sub(1)); + Some((InputResult::None, true)) + } else if textarea.cursor() == 0 { + self.selected_remote_image_index = Some(self.remote_image_urls.len() - 1); + Some((InputResult::None, true)) + } else { + None + } + } + KeyCode::Down => { + if let Some(selected) = self.selected_remote_image_index { + if selected + 1 < self.remote_image_urls.len() { + self.selected_remote_image_index = Some(selected + 1); + } else { + self.clear_remote_image_selection(); + } + Some((InputResult::None, true)) + } else { + None + } + } + KeyCode::Delete | KeyCode::Backspace => { + if let Some(selected) = self.selected_remote_image_index { + self.remove_selected_remote_image(selected, textarea); + Some((InputResult::None, true)) + } else { + None + } + } + _ => None, + } + } + + pub(super) fn remove_deleted_local_placeholders( + &mut self, + removed_payloads: &[String], + textarea: &mut TextArea, + ) -> bool { + let previous_len = self.local_images.len(); + self.local_images.retain(|image| { + !removed_payloads + .iter() + .any(|payload| payload == &image.placeholder) + }); + let removed_any = self.local_images.len() != previous_len; + if removed_any { + self.relabel_local_images(textarea); + } + removed_any + } + + pub(super) fn relabel_local_images(&mut self, textarea: &mut TextArea) { + for (index, image) in self.local_images.iter_mut().enumerate() { + let expected = local_image_label_text(self.remote_image_urls.len() + index + 1); + if image.placeholder == expected { + continue; + } + + let current = std::mem::replace(&mut image.placeholder, expected.clone()); + let _renamed = textarea.replace_element_payload(¤t, &expected); + } + } + + fn remove_selected_remote_image(&mut self, selected_index: usize, textarea: &mut TextArea) { + if selected_index >= self.remote_image_urls.len() { + self.clear_remote_image_selection(); + return; + } + + self.remote_image_urls.remove(selected_index); + self.selected_remote_image_index = if self.remote_image_urls.is_empty() { + None + } else { + Some(selected_index.min(self.remote_image_urls.len() - 1)) + }; + self.relabel_local_images(textarea); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/completion_target.rs b/codex-rs/tui/src/bottom_pane/chat_composer/completion_target.rs new file mode 100644 index 0000000000000000000000000000000000000000..22443e3dce4fa3145ad251e4126de659709d6024 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/completion_target.rs @@ -0,0 +1,358 @@ +//! Cursor-neighborhood resolution for sigil-prefixed composer completions. + +use super::ends_plaintext_at_dollar_mention; +use super::ends_plaintext_at_mention; +use super::is_mention_name_char; +use crate::bottom_pane::textarea::TextArea; +use crate::mention_codec::is_common_env_var; +use std::ops::Range; + +/// Narrows one whitespace-delimited candidate to the editable segment around `anchor`. +/// +/// Atomic elements form hard boundaries. If the anchor is inside an element, the raw candidate is +/// retained only when it is needed to identify that the candidate is already bound. +fn prefixed_candidate_range( + textarea: &TextArea, + range: Range, + anchor: usize, + prefix: char, + allow_empty: bool, +) -> Option<(Range, String)> { + let text = textarea.text(); + let prefix_len = prefix.len_utf8(); + let raw_prefixed = text + .get(range.clone()) + .filter(|token| token.starts_with(prefix)) + .map(|token| (range.clone(), token[prefix_len..].to_string())); + let mut segment_start = range.start; + let mut segment_end = range.end; + let mut has_element_boundary = false; + let anchor = anchor.clamp(range.start, range.end); + + for element in textarea.text_element_ranges_overlapping(range) { + has_element_boundary = true; + if element.end <= anchor { + segment_start = element.end; + continue; + } + if anchor <= element.start { + segment_end = element.start; + } else { + segment_start = anchor; + segment_end = anchor; + } + break; + } + + if !has_element_boundary { + return raw_prefixed; + } + + let segmented_prefixed = text + .get(segment_start..segment_end) + .and_then(|token| token.strip_prefix(prefix)) + .filter(|query| allow_empty || !query.is_empty()) + .map(|query| (segment_start..segment_end, query.to_string())); + if segmented_prefixed.is_some() + || raw_prefixed.as_ref().is_none_or(|(range, query)| { + prefixed_token_range_is_editable(textarea, prefix, range, query) + }) + { + segmented_prefixed + } else { + raw_prefixed + } +} + +/// Extracts a token prefixed with `prefix` under the cursor, if any. +/// +/// The returned string does not include the prefix. Resolution considers the tokens on both sides +/// of same-line separator whitespace and preserves their ranges for cross-sigil arbitration. It +/// stops editable candidates at atomic text elements, and line breaks never provide affinity. +pub(super) fn current_prefixed_token_range( + textarea: &TextArea, + prefix: char, + allow_empty: bool, +) -> Option<(Range, String)> { + current_prefixed_token_range_with_dollar_predicate( + textarea, + prefix, + allow_empty, + dollar_query_is_completable, + ) +} + +/// Extracts a prefixed token while using the provided dollar-query predicate for separator +/// arbitration. +pub(super) fn current_prefixed_token_range_with_dollar_predicate( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + dollar_query_is_completable: impl Fn(&str) -> bool, +) -> Option<(Range, String)> { + let cursor_offset = textarea.cursor(); + let text = textarea.text(); + + // Adjust the provided byte offset to the nearest valid char boundary at or before it. + let mut safe_cursor = cursor_offset.min(text.len()); + if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) { + safe_cursor = text + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= cursor_offset) + .last() + .unwrap_or(0); + } + + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + let is_horizontal_whitespace = |c: char| { + c.is_whitespace() + && !matches!( + c, + '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}' + ) + }; + + let at_whitespace = after_cursor.chars().next().is_some_and(char::is_whitespace); + let after_horizontal_whitespace = before_cursor + .chars() + .next_back() + .is_some_and(is_horizontal_whitespace); + let cursor_starts_token = after_horizontal_whitespace && !at_whitespace; + let next_non_separator = after_cursor.chars().find(|c| !is_horizontal_whitespace(*c)); + let separator_precedes_token = next_non_separator.is_some_and(|c| !c.is_whitespace()); + let separator_precedes_completion = next_non_separator.is_some_and(|c| matches!(c, '$' | '@')); + let at_separator = (at_whitespace || after_horizontal_whitespace) && separator_precedes_token; + + let end_left = if at_separator { + before_cursor + .trim_end_matches(is_horizontal_whitespace) + .len() + } else { + let end_left_rel = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + safe_cursor + end_left_rel + }; + let start_left = text[..end_left] + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + + let ws_len_right: usize = after_cursor + .chars() + .take_while(|c| is_horizontal_whitespace(*c)) + .map(char::len_utf8) + .sum(); + let start_right = safe_cursor + ws_len_right; + let end_right_rel = text[start_right..] + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(text.len() - start_right); + let end_right = start_right + end_right_rel; + let token_right = if start_right < end_right { + Some(&text[start_right..end_right]) + } else { + None + }; + + let left_prefixed = prefixed_candidate_range( + textarea, + start_left..end_left, + safe_cursor, + prefix, + allow_empty, + ); + let right_prefixed = prefixed_candidate_range( + textarea, + start_right..end_right, + start_right, + prefix, + allow_empty, + ); + + if cursor_starts_token { + let right_is_bound = token_right + .and_then(|token| { + let prefix = token.chars().next()?; + matches!(prefix, '$' | '@').then_some((prefix, &token[prefix.len_utf8()..])) + }) + .is_some_and(|(prefix, query)| { + !prefixed_token_range_is_editable( + textarea, + prefix, + &(start_right..end_right), + query, + ) + }); + return if right_is_bound { + left_prefixed + } else { + right_prefixed + }; + } + + if allow_empty && after_cursor.starts_with(prefix) { + let left_fragment = &text[start_left..safe_cursor]; + if let Some(left_token) = left_fragment.strip_prefix(prefix) + && left_token + .as_bytes() + .iter() + .all(|byte| is_mention_name_char(*byte)) + { + let left_range = start_left..safe_cursor; + let left_is_editable = + prefixed_token_range_is_editable(textarea, prefix, &left_range, left_token); + if left_token.is_empty() || prefix == '$' && left_is_editable { + return Some((left_range, left_token.to_string())); + } + if !left_is_editable { + return left_prefixed.or(right_prefixed); + } + } + } + + if at_separator { + if after_horizontal_whitespace && !separator_precedes_completion { + return right_prefixed; + } + if prefix == '$' + && left_prefixed + .as_ref() + .is_some_and(|(_, query)| !dollar_query_is_completable(query)) + && right_prefixed + .as_ref() + .is_some_and(|(_, query)| dollar_query_is_completable(query)) + { + return right_prefixed; + } + if prefix == '@' + && right_prefixed.as_ref().is_some_and(|(range, query)| { + prefixed_token_range_is_editable(textarea, prefix, range, query) + }) + { + return right_prefixed; + } + if left_prefixed.as_ref().is_some_and(|(range, token)| { + !prefixed_token_range_is_editable(textarea, prefix, range, token) + }) { + return right_prefixed.or(left_prefixed); + } + if left_prefixed + .as_ref() + .is_some_and(|(_, token)| token.is_empty()) + && !allow_empty + { + return right_prefixed; + } + return left_prefixed.or(right_prefixed); + } + if after_cursor.starts_with(prefix) { + let prefix_starts_token = before_cursor + .chars() + .next_back() + .is_none_or(char::is_whitespace); + return if prefix_starts_token { + right_prefixed.or(left_prefixed) + } else { + left_prefixed + }; + } + left_prefixed.or(right_prefixed) +} + +/// Returns whether a token candidate's sigil and mention name are editable plaintext. +/// +/// A candidate is bound when either its entire range is atomic or its mention-name prefix is +/// atomic and the remaining text begins with a terminator for that sigil. +pub(super) fn prefixed_token_range_is_editable( + textarea: &TextArea, + prefix: char, + range: &Range, + token: &str, +) -> bool { + if textarea.element_id_for_exact_range(range.clone()).is_some() { + return false; + } + + let name_len = token + .as_bytes() + .iter() + .take_while(|byte| is_mention_name_char(**byte)) + .count(); + let mention_end = range.start + prefix.len_utf8() + name_len; + let ends_bound_mention = if prefix == '@' { + ends_plaintext_at_mention(textarea.text().as_bytes(), mention_end) + } else { + ends_plaintext_at_dollar_mention(textarea.text().as_bytes(), mention_end) + }; + !(name_len > 0 + && mention_end < range.end + && ends_bound_mention + && textarea + .element_id_for_exact_range(range.start..mention_end) + .is_some()) +} + +/// Rejects shell-like dollar syntax while preserving lowercase and plugin-qualified skill queries. +/// +/// Uppercase common environment-variable spellings and tokens beginning with a positional or +/// special shell parameter are excluded. The check uses the complete colon-qualified name so a +/// skill such as `home` or `home:search` is not mistaken for `$HOME`. +pub(super) fn dollar_query_is_completable(query: &str) -> bool { + matches!(dollar_query_kind(query), DollarQueryKind::Completable) +} + +/// Classifies text after `$` for arbitration between shell syntax and mention completion. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum DollarQueryKind { + Completable, + ShellVariable, + DefiniteShellParameter, + /// A digit-leading query containing a non-digit, or a `-` query with a suffix. + AmbiguousShellParameter, + Invalid, +} + +/// Conservatively classifies a dollar query before the mention catalog is consulted. +/// +/// Numeric-only and exact special-parameter forms remain definite shell syntax. A digit-leading +/// query containing a non-digit, or a `-` query with a suffix, is ambiguous because loaded +/// mentions may legally use those spellings. +pub(super) fn dollar_query_kind(query: &str) -> DollarQueryKind { + let name_end = query + .as_bytes() + .iter() + .take_while(|byte| is_mention_name_char(**byte) || **byte == b':') + .count(); + let name = &query[..name_end]; + let is_shell_var = + name.bytes().all(|byte| !byte.is_ascii_lowercase()) && is_common_env_var(name); + let is_shell_parameter = name + .as_bytes() + .first() + .is_some_and(|byte| *byte == b'-' || byte.is_ascii_digit()); + let is_numeric_parameter = !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()); + if query.is_empty() { + DollarQueryKind::Completable + } else if name_end == 0 { + DollarQueryKind::Invalid + } else if is_shell_var { + DollarQueryKind::ShellVariable + } else if is_numeric_parameter || matches!(name, "-" | "_") { + DollarQueryKind::DefiniteShellParameter + } else if is_shell_parameter { + DollarQueryKind::AmbiguousShellParameter + } else { + DollarQueryKind::Completable + } +} + +#[cfg(test)] +#[path = "completion_target_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs new file mode 100644 index 0000000000000000000000000000000000000000..ec966b8d73e998eb23a47e1cd6c61aaaf666052c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/composer_layout.rs @@ -0,0 +1,36 @@ +//! Reserves popup and voice-control space around the editable draft. + +use super::ChatComposer; +use crate::bottom_pane::voice_strip::VoiceStrip; +use crate::bottom_pane::voice_strip::VoiceStripState; +use crate::render::renderable::Renderable; +use crate::tui::FrameRequester; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +impl ChatComposer { + pub(crate) fn set_voice_strip( + &mut self, + state: Option, + frame_requester: FrameRequester, + ) { + match (self.voice_strip.as_mut(), state) { + (Some(strip), Some(state)) => strip.update(state), + (_, Some(state)) => self.voice_strip = Some(VoiceStrip::new(state, frame_requester)), + (_, None) => self.voice_strip = None, + } + } + + pub(super) fn render_voice_strip(&self, composer_rect: Rect, buf: &mut Buffer) { + if let Some(strip) = &self.voice_strip + && composer_rect.height >= 6 + { + let voice_rect = Rect { + y: composer_rect.y.saturating_add(/*rhs*/ 1), + height: 2, + ..composer_rect + }; + strip.render(voice_rect, buf); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs new file mode 100644 index 0000000000000000000000000000000000000000..b219097ee18118ba7be29fd24c514d7e64b2cfb5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -0,0 +1,46 @@ +//! Editable composer draft state kept separate from composer control flow. + +use std::cell::RefCell; +use std::collections::HashMap; + +use crate::bottom_pane::MentionBinding; +use crate::bottom_pane::paste_burst::PasteBurst; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; + +pub(super) struct DraftState { + pub(super) textarea: TextArea, + pub(super) textarea_state: RefCell, + pub(super) is_bash_mode: bool, + pub(super) pending_pastes: Vec<(String, String)>, + pub(super) input_enabled: bool, + pub(super) input_disabled_placeholder: Option, + pub(super) paste_burst: PasteBurst, + pub(super) disable_paste_burst: bool, + pub(super) mention_bindings: HashMap, + pub(super) recent_submission_mention_bindings: Vec, +} + +impl DraftState { + pub(super) fn new() -> Self { + Self { + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + is_bash_mode: false, + pending_pastes: Vec::new(), + input_enabled: true, + input_disabled_placeholder: None, + paste_burst: PasteBurst::default(), + disable_paste_burst: false, + mention_bindings: HashMap::new(), + recent_submission_mention_bindings: Vec::new(), + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct ComposerMentionBinding { + pub(super) sigil: char, + pub(super) mention: String, + pub(super) path: String, +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs new file mode 100644 index 0000000000000000000000000000000000000000..009c4831f6bb2e2e888d9bf7384824de89d2f90b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -0,0 +1,86 @@ +//! Footer and status-row presentation state for the chat composer. +//! Owners schedule flash expiry redraws; replacing a draft clears its flash. + +use std::time::Instant; + +use ratatui::text::Line; + +use crate::bottom_pane::footer::CollaborationModeIndicator; +use crate::bottom_pane::footer::FooterMode; +use crate::bottom_pane::footer::GoalStatusIndicator; +use crate::key_hint::KeyBinding; +use crate::key_hint::ShortcutHint; +use std::time::Duration; + +impl super::ChatComposer { + pub(crate) fn footer_flash_delay(&self) -> Option { + self.footer + .flash + .as_ref() + .and_then(|flash| flash.expires_at.checked_duration_since(Instant::now())) + } + + pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { + self.footer.show_flash(line, duration); + } +} + +pub(super) struct FooterState { + pub(super) quit_shortcut_expires_at: Option, + pub(super) quit_shortcut_key: KeyBinding, + pub(super) esc_backtrack_hint: bool, + pub(super) use_shift_enter_hint: bool, + pub(super) mode: FooterMode, + pub(super) hint_override: Option>, + pub(super) flash: Option, + pub(super) context_window_percent: Option, + pub(super) context_window_used_tokens: Option, + pub(super) context_window_pending: bool, + pub(super) collaboration_mode_indicator: Option, + pub(super) goal_status_indicator: Option, + pub(super) ide_context_active: bool, + pub(super) status_line_value: Option>, + pub(super) status_line_hyperlink_url: Option, + pub(super) status_line_enabled: bool, + pub(super) side_conversation_context_label: Option, + pub(super) active_agent_label: Option, + pub(super) external_editor_key: Option, + pub(super) show_transcript_key: Option, + pub(super) insert_newline_key: Option, + pub(super) queue_key: Option, + pub(super) toggle_shortcuts_key: Option, + pub(super) history_search_key: Option, + pub(super) reasoning_down_key: Option, + pub(super) reasoning_up_key: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct FooterFlash { + pub(super) line: Line<'static>, + pub(super) expires_at: Instant, +} + +impl FooterState { + pub(super) fn flash_visible(&self) -> bool { + self.flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + + pub(super) fn show_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.flash = Some(FooterFlash { line, expires_at }); + } + + #[cfg(test)] + pub(super) fn status_line_text(&self) -> Option { + self.status_line_value.as_ref().map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs new file mode 100644 index 0000000000000000000000000000000000000000..419a3b6f2592a91025ffc12b89e147e92dbdb130 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -0,0 +1,1197 @@ +//! Composer-side Ctrl+R reverse history search state and rendering helpers. +//! +//! The persistent and local history stores live in `chat_composer_history`, but the composer owns +//! the active search session because it has to snapshot/restore the editable draft and Vim edit +//! state, preview matches in the textarea, and render the footer prompt while the footer line is +//! acting as the search input. +//! +//! This module is responsible for the UI-facing lifecycle of a search session: recognizing the +//! keys that enter and drive search mode, keeping the footer query separate from the textarea +//! preview, restoring the original draft on cancellation or misses, and translating history search +//! results into composer-visible state. It deliberately does not decide which history entries +//! match, how duplicate results are skipped, or when persistent history should be fetched; those +//! traversal invariants stay with `ChatComposerHistory`. +//! +//! A search session starts idle with an empty footer query, so opening Ctrl+R never previews the +//! latest history entry by itself. Typing or pasting a query restarts traversal from newest to oldest, +//! repeated Ctrl+R/Up and Ctrl+S/Down move between unique matches, `Enter` accepts the current +//! preview as an editable draft, and `Esc` or Ctrl+C restores the exact draft that existed before +//! search started. + +use std::ops::Range; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; + +use super::super::chat_composer_history::HistorySearchDirection; +use super::super::chat_composer_history::HistorySearchResult; +use super::super::footer::footer_height; +use super::super::footer::reset_mode_after_activity; +use super::super::textarea::VimPersistentState; +use super::ActivePopup; +use super::ChatComposer; +use super::ComposerDraft; +use super::InputResult; +use super::vim_history::VimHistory; +use crate::app_event::AppEvent; +use crate::key_hint; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::has_ctrl_or_alt; +use crate::ui_consts::FOOTER_INDENT_COLS; + +/// Active composer-owned state for one Ctrl+R search interaction. +/// +/// The session is created only by [`ChatComposer::begin_history_search`] and is cleared only by +/// accepting, canceling, or replacing the search mode. It stores the original draft and Vim edit +/// state separately from the footer query so transient previews never destroy in-progress content. +#[derive(Debug)] +pub(super) struct HistorySearchSession { + /// Draft to restore when search is canceled or a query has no match. + original_draft: ComposerDraft, + /// Same-draft Vim edits to restore when a temporary preview is canceled. + original_vim_history: VimHistory, + /// Active and completed Vim commands suspended during temporary draft replacement. + original_vim_state: VimPersistentState, + /// Footer-owned query text typed or pasted while Ctrl+R search is active. + query: String, + /// User-visible search status used to choose footer hints and composer preview behavior. + status: HistorySearchStatus, +} + +impl HistorySearchSession { + /// Renders newlines and tabs as visible markers for the footer and cursor placement. + /// Matching continues to use the original query. + fn display_query(&self) -> String { + self.query.replace('\n', "↵").replace('\t', "⇥") + } +} + +/// User-visible phase of the active Ctrl+R search session. +/// +/// Search keeps the footer query and the composer preview separate: `Idle` leaves the original +/// draft untouched, `Searching` waits for persistent history, `Match` previews a found entry, and +/// `NoMatch` restores the original draft while leaving the search input open for more typing. +#[derive(Clone, Debug)] +enum HistorySearchStatus { + Idle, + Searching, + Match, + NoMatch, +} + +impl ChatComposer { + #[cfg(test)] + pub(super) fn history_search_active(&self) -> bool { + self.history_search.is_some() + } + + /// Returns whether a key event should open reverse history search or step to an older match. + /// + /// The check accepts both normal Ctrl+R reports and the raw control character variant that + /// some terminals emit. Callers should only use this before generic text handling; treating the + /// raw control character as ordinary input would insert an invisible byte into the search query + /// or composer draft. + pub(super) fn is_history_search_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool { + bindings.is_pressed(*key_event) + } + + fn is_history_search_forward_key(key_event: &KeyEvent, bindings: &[KeyBinding]) -> bool { + bindings.is_pressed(*key_event) + } + + /// Opens footer-owned reverse history search without previewing history yet. + /// + /// Entering search mode first flushes pending paste-burst text, then snapshots the full + /// composer draft, clears any file/search popup state, and resets history traversal. The first + /// visible match is produced only after the footer query becomes non-empty, which keeps Ctrl+R + /// from replacing an empty composer with the latest prompt before the user has searched for + /// anything. + pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + self.draft.paste_burst.clear_window_after_non_char(); + + if self.popups.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.popups.current_file_query = None; + } + self.popups.active = ActivePopup::None; + self.attachments.clear_remote_image_selection(); + let original_draft = self.snapshot_draft(); + let original_vim_history = std::mem::take(&mut self.vim_history); + let mut original_vim_state = VimPersistentState::default(); + self.draft + .textarea + .swap_vim_persistent_state(&mut original_vim_state); + self.history_search = Some(HistorySearchSession { + original_draft, + original_vim_history, + original_vim_state, + query: String::new(), + status: HistorySearchStatus::Idle, + }); + self.history.reset_search(); + (InputResult::None, true) + } + + /// Handles every key while the footer is acting as the history search input. + /// + /// The method consumes search-mode keys before normal composer editing sees them. It guarantees + /// that `Esc` and Ctrl+C restore the original draft, `Enter` only accepts an actual match, plain + /// characters edit the footer query, and navigation keys delegate traversal to + /// `ChatComposerHistory`. Calling this when no search session exists is harmless for ignored + /// keys but would make query-edit branches no-op, so route here only after + /// `history_search.is_some()` has been established. + pub(super) fn handle_history_search_key(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if key_event.kind == KeyEventKind::Release { + return (InputResult::None, false); + } + + if Self::is_history_search_key(&key_event, &self.history_search_previous_keys) + || matches!(key_event.code, KeyCode::Up) + { + let result = self.history_search_in_direction(HistorySearchDirection::Older); + return (result, true); + } + + if Self::is_history_search_forward_key(&key_event, &self.history_search_next_keys) + || matches!(key_event.code, KeyCode::Down) + { + let result = self.history_search_in_direction(HistorySearchDirection::Newer); + return (result, true); + } + + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.cancel_history_search(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { + self.cancel_history_search(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char('\u{0003}'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.cancel_history_search(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + if self + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + { + self.history_search = None; + self.history.reset_search(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.move_cursor_to_end(); + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Backspace, + .. + } + | KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.update_history_search_query(|query| { + query.pop(); + }); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.update_history_search_query(String::clear); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } if !has_ctrl_or_alt(modifiers) => { + self.update_history_search_query(|query| query.push(ch)); + (InputResult::None, true) + } + _ => (InputResult::None, true), + } + } + + fn history_search_in_direction(&mut self, direction: HistorySearchDirection) -> InputResult { + let Some((query, original_draft)) = self + .history_search + .as_ref() + .map(|search| (search.query.clone(), search.original_draft.clone())) + else { + return InputResult::None; + }; + if query.is_empty() { + self.history.reset_search(); + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Idle; + } + self.restore_draft(original_draft); + return InputResult::None; + } + let result = self.history.search( + &query, + direction, + /*restart*/ false, + &self.app_event_tx, + ); + self.apply_history_search_result(result); + InputResult::None + } + + /// Edits the footer query and restarts history traversal from the newest entry. + /// An empty query restores the original draft and leaves search open. + pub(super) fn update_history_search_query(&mut self, edit: impl FnOnce(&mut String)) { + let Some(search) = self.history_search.as_mut() else { + return; + }; + edit(&mut search.query); + search.status = HistorySearchStatus::Searching; + let query = search.query.clone(); + let original_draft = search.original_draft.clone(); + self.restore_draft(original_draft); + if query.is_empty() { + self.history.reset_search(); + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Idle; + } + return; + } + let result = self.history.search( + &query, + HistorySearchDirection::Older, + /*restart*/ true, + &self.app_event_tx, + ); + self.apply_history_search_result(result); + } + + /// Cancels active history search and restores the draft from before search mode opened. + /// + /// This clears normal history navigation as well as search traversal because previewing a match + /// temporarily updates the shared history cursor. Callers that handle global cancellation, such + /// as Ctrl+C, should use the boolean result to consume the key without also clearing the + /// restored draft or triggering quit/interrupt behavior. + pub(crate) fn cancel_history_search(&mut self) -> bool { + let Some(mut search) = self.history_search.take() else { + return false; + }; + self.history.reset_navigation(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.restore_draft(search.original_draft); + self.vim_history = search.original_vim_history; + self.draft + .textarea + .swap_vim_persistent_state(&mut search.original_vim_state); + true + } + + /// Applies a traversal result to the composer preview and search status. + /// + /// `Found` previews the matching entry, `Pending` keeps the footer in a waiting state while an + /// async persistent entry lookup is outstanding, `AtBoundary` preserves the current match, and + /// `NotFound` restores the original draft while keeping the query available for further edits, + /// and `Unavailable` does the same without claiming there was no match. Treating `AtBoundary` + /// like `NotFound` would produce the visible "no match" flicker at the end of a one-result + /// search and desynchronize Up/Down counts. + pub(super) fn apply_history_search_result(&mut self, result: HistorySearchResult) { + match result { + HistorySearchResult::Found(entry) => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Match; + } + self.apply_history_entry(entry); + } + HistorySearchResult::Pending => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Searching; + } + } + HistorySearchResult::AtBoundary => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Match; + } + } + result @ (HistorySearchResult::NotFound | HistorySearchResult::Unavailable) => { + let original_draft = self + .history_search + .as_ref() + .map(|search| search.original_draft.clone()); + if let Some(search) = self.history_search.as_mut() { + search.status = if matches!(result, HistorySearchResult::NotFound) { + HistorySearchStatus::NoMatch + } else { + HistorySearchStatus::Idle + }; + } + if let Some(original_draft) = original_draft { + self.restore_draft(original_draft); + } + } + } + } + + /// Builds the footer line shown while reverse history search is active. + /// + /// The footer displays the query as the editable field and uses the status to decide whether + /// to show searching, match actions, or no-match feedback. Newlines and tabs use visible markers + /// while matching keeps the original query. The line is intentionally separate from cursor + /// placement so rendering can fall back to normal footer layout if a small terminal cannot + /// allocate a distinct hint row. + pub(super) fn history_search_footer_line(&self) -> Option> { + let search = self.history_search.as_ref()?; + let mut line = Line::from(vec![ + "reverse-i-search: ".dim(), + search.display_query().cyan(), + ]); + match search.status { + HistorySearchStatus::Idle => {} + HistorySearchStatus::Searching => line.push_span(" searching".dim()), + HistorySearchStatus::Match => { + line.push_span(" ".dim()); + line.push_span(Self::history_search_action_key_span(KeyCode::Enter)); + line.push_span(" accept".dim()); + line.push_span(" · ".dim()); + line.push_span(Self::history_search_action_key_span(KeyCode::Esc)); + line.push_span(" cancel".dim()); + } + HistorySearchStatus::NoMatch => line.push_span(" no match".red()), + } + Some(line) + } + + fn history_search_action_key_span(key: KeyCode) -> Span<'static> { + Span::from(key_hint::plain(key)).cyan().bold().not_dim() + } + + /// Returns byte ranges that should be highlighted in the current composer preview. + /// + /// Highlights are only exposed while a matched history entry is being previewed. Once the user + /// accepts with `Enter`, the search session is cleared and this returns an empty set so the + /// accepted text becomes an ordinary editable draft again. + pub(super) fn history_search_highlight_ranges(&self) -> Vec> { + let Some(search) = self.history_search.as_ref() else { + return Vec::new(); + }; + if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() { + return Vec::new(); + } + Self::case_insensitive_match_ranges(self.draft.textarea.text(), &search.query) + } + + fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec> { + if query.is_empty() { + return Vec::new(); + } + + let query_lower = query + .chars() + .flat_map(char::to_lowercase) + .collect::(); + if query_lower.is_empty() { + return Vec::new(); + } + + let mut folded = String::new(); + let mut folded_spans: Vec<(Range, Range)> = Vec::new(); + for (original_start, ch) in text.char_indices() { + let original_range = original_start..original_start + ch.len_utf8(); + for lower in ch.to_lowercase() { + let folded_start = folded.len(); + folded.push(lower); + folded_spans.push((folded_start..folded.len(), original_range.clone())); + } + } + + let mut ranges = Vec::new(); + let mut search_from = 0; + // Use two-pointer method to find matches in linear time. + let mut start_span = 0; + let mut end_span = 0; + while search_from <= folded.len() + && let Some(relative_start) = folded[search_from..].find(&query_lower) + { + let folded_start = search_from + relative_start; + let folded_end = folded_start + query_lower.len(); + while folded_spans[start_span].0.end <= folded_start { + start_span += 1; + } + while folded_spans[end_span].0.end < folded_end { + end_span += 1; + } + ranges.push(folded_spans[start_span].1.start..folded_spans[end_span].1.end); + search_from = folded_end; + } + ranges + } + + /// Returns the screen cursor position for the footer query when search mode is active. + /// + /// The cursor tracks the end of the footer query rather than the textarea preview. If the + /// footer area is collapsed or too narrow, the x coordinate is clamped inside the hint rect so + /// terminal backends do not receive an off-screen cursor position. + pub(super) fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.history_search.as_ref()?; + let [_, _, _, popup_rect] = self.layout_areas(area); + if popup_rect.is_empty() { + return None; + } + + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(&footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + }; + if hint_rect.is_empty() { + return None; + } + + let indent = (FOOTER_INDENT_COLS as u16).min(hint_rect.width.saturating_sub(1)); + self.history_search_query_cursor_pos(Rect { + x: hint_rect.x.saturating_add(indent), + width: hint_rect.width.saturating_sub(indent), + ..hint_rect + }) + } + + pub(super) fn history_search_query_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let search = self.history_search.as_ref()?; + if area.is_empty() { + return None; + } + let prompt_width = Line::from("reverse-i-search: ").width() as u16; + let query_width = + u16::try_from(Line::from(search.display_query()).width()).unwrap_or(u16::MAX); + let desired_x = area + .x + .saturating_add(prompt_width) + .saturating_add(query_width); + let max_x = area.x.saturating_add(area.width.saturating_sub(1)); + Some((desired_x.min(max_x), area.y)) + } +} + +#[cfg(test)] +#[path = "history_search_paste_tests.rs"] +mod paste_tests; + +#[cfg(test)] +mod tests { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Modifier; + use tokio::sync::mpsc::unbounded_channel; + + use super::super::super::chat_composer_history::HistoryEntry; + use super::super::super::chat_composer_history::HistorySearchResult; + use super::super::super::footer::FooterMode; + use super::super::ChatComposer; + use super::super::InputResult; + use super::super::tests::type_chars_humanlike; + use super::HistorySearchStatus; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::render::renderable::Renderable; + + #[test] + fn vim_normal_j_k_navigate_history_at_history_boundaries() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_vim_enabled(/*enabled*/ true); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert!(composer.draft.textarea.is_empty()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + } + + #[test] + fn remapped_vim_normal_history_navigation_does_not_fall_back_to_j_k() { + use crate::key_hint; + use crate::keymap::RuntimeKeymap; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + let mut keymap = RuntimeKeymap::defaults(); + keymap.vim_normal.move_up = vec![key_hint::plain(KeyCode::F(2))]; + keymap.vim_normal.move_down = vec![key_hint::plain(KeyCode::F(3))]; + composer.set_keymap_bindings(&keymap); + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert!(composer.draft.textarea.is_empty()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "first"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)); + assert!(composer.draft.textarea.is_empty()); + } + + #[test] + fn vim_normal_j_k_fall_back_to_multiline_cursor_movement() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .draft + .textarea + .set_text_clearing_elements("one\ntwo"); + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), "one\n".len()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.cursor(), 0); + } + + #[test] + fn vim_normal_operator_motion_does_not_navigate_history() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['f', 'i', 'r', 's', 't']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + type_chars_humanlike(&mut composer, &['s', 'e', 'c', 'o', 'n', 'd']); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::Submitted { .. })); + + composer.set_vim_enabled(/*enabled*/ true); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "second"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert!(composer.draft.textarea.is_empty()); + assert_eq!(composer.current_text(), ""); + } + + #[test] + fn history_search_opens_without_previewing_latest_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("remembered command".to_string())); + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + + assert!(composer.history_search_active()); + assert!(composer.draft.textarea.is_empty()); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + } + + #[test] + fn unavailable_history_search_restores_idle_draft() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer.begin_history_search(); + composer.apply_history_search_result(HistorySearchResult::Pending); + + composer.apply_history_search_result(HistorySearchResult::Unavailable); + + assert_eq!(composer.draft.textarea.text(), "draft"); + assert!( + composer + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Idle)) + ); + } + + #[test] + fn history_search_match_ranges_are_case_insensitive() { + assert_eq!( + ChatComposer::case_insensitive_match_ranges("git status git", "GIT"), + vec![0..3, 11..14] + ); + assert_eq!( + ChatComposer::case_insensitive_match_ranges("aİ i", "i"), + vec![1..3, 4..5] + ); + assert!(ChatComposer::case_insensitive_match_ranges("git", "").is_empty()); + } + + #[test] + fn history_search_match_ranges_preserve_unicode_boundaries() { + for (text, query, expected) in [ + ("İİ", "i", vec![0..2, 2..4]), + ("İİ", "\u{307}", vec![0..2, 2..4]), + ("İİİ", "\u{307}i", vec![0..4, 2..6]), + ("éÉ é", "É", vec![0..2, 2..4, 5..7]), + ("aaaaa", "aa", vec![0..2, 2..4]), + ("", "x", vec![]), + ("abc", "z", vec![]), + ] { + assert_eq!( + ChatComposer::case_insensitive_match_ranges(text, query), + expected, + "text: {text:?}, query: {query:?}" + ); + } + } + + #[test] + fn history_search_accepts_matching_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "draft"); + + for ch in ['g', 'i', 't'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); + } + + #[test] + fn vim_normal_history_search_preview_places_cursor_on_last_char() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + composer.set_vim_enabled(/*enabled*/ true); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.vim_normal.redo.clear(); + composer.set_keymap_bindings(&keymap); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['g', 'i', 't'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!(composer.draft.textarea.cursor(), "git status".len() - 1); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + } + + #[test] + fn history_search_stays_on_single_match_at_boundaries() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.history.record_local_submission(HistoryEntry::new( + "Find and fix a bug in @filename".to_string(), + )); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['b', 'u', 'g'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); + + for _ in 0..3 { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + } + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); + assert!( + composer + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + ); + + for _ in 0..3 { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); + assert!( + composer + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + ); + } + + #[test] + fn history_search_footer_action_hints_are_emphasized() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + + let line = composer + .history_search_footer_line() + .expect("expected history search footer line"); + assert_eq!( + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>(), + vec![ + "reverse-i-search: ", + "c", + " ", + "enter", + " accept", + " · ", + "esc", + " cancel" + ] + ); + + let query_style = line.spans[1].style; + assert_eq!(query_style.fg, Some(ratatui::style::Color::Cyan)); + + let enter_style = line.spans[3].style; + assert_eq!(enter_style.fg, Some(ratatui::style::Color::Cyan)); + assert!(enter_style.add_modifier.contains(Modifier::BOLD)); + assert!(enter_style.sub_modifier.contains(Modifier::DIM)); + + let accept_style = line.spans[4].style; + assert!(accept_style.add_modifier.contains(Modifier::DIM)); + + let separator_style = line.spans[5].style; + assert!(separator_style.add_modifier.contains(Modifier::DIM)); + + let esc_style = line.spans[6].style; + assert_eq!(esc_style.fg, Some(ratatui::style::Color::Cyan)); + assert!(esc_style.add_modifier.contains(Modifier::BOLD)); + assert!(esc_style.sub_modifier.contains(Modifier::DIM)); + + let cancel_style = line.spans[7].style; + assert!(cancel_style.add_modifier.contains(Modifier::DIM)); + } + + #[test] + fn history_search_highlights_matches_until_accepted() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['g', 'i', 't'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + let area = Rect::new(0, 0, 60, 8); + let [_, _, textarea_rect, _] = composer.layout_areas(area); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + let x = textarea_rect.x; + let y = textarea_rect.y; + assert_eq!(buf[(x, y)].symbol(), "g"); + for offset in 0..3 { + let modifier = buf[(x + offset, y)].style().add_modifier; + assert!(modifier.contains(Modifier::REVERSED)); + assert!(modifier.contains(Modifier::BOLD)); + } + assert!( + !buf[(x + 3, y)] + .style() + .add_modifier + .contains(Modifier::REVERSED) + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let [_, _, accepted_textarea_rect, _] = composer.layout_areas(area); + let mut accepted_buf = Buffer::empty(area); + composer.render(area, &mut accepted_buf); + for offset in 0..3 { + let modifier = accepted_buf + [(accepted_textarea_rect.x + offset, accepted_textarea_rect.y)] + .style() + .add_modifier; + assert!(!modifier.contains(Modifier::REVERSED)); + assert!(!modifier.contains(Modifier::BOLD)); + } + } + + #[test] + fn history_search_esc_restores_original_draft() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("remembered command".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(/*pos*/ 2); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(composer.draft.textarea.text(), "draft"); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "remembered command"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.cursor(), 2); + } + + #[test] + fn history_search_ctrl_c_restores_original_draft() { + fn composer_with_search_preview() -> ChatComposer { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("remembered command".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(/*pos*/ 2); + + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "remembered command"); + composer + } + + for cancel_key in [ + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('\u{0003}'), KeyModifiers::NONE), + ] { + let mut composer = composer_with_search_preview(); + + let _ = composer.handle_key_event(cancel_key); + + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.cursor(), 2); + } + } + + #[test] + fn history_search_flushes_pending_first_char_before_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.draft.textarea.text(), ""); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + + assert!(composer.history_search_active()); + assert!(!composer.is_in_paste_burst()); + assert_eq!(composer.draft.textarea.text(), "h"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "h"); + } + + #[test] + fn history_search_flushes_buffered_paste_before_snapshot() { + use std::time::Duration; + use std::time::Instant; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mut now = Instant::now(); + for ch in ['p', 'a', 's', 't', 'e'] { + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); + now += Duration::from_millis(1); + } + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.draft.textarea.text(), ""); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + + assert!(composer.history_search_active()); + assert!(!composer.is_in_paste_burst()); + assert_eq!(composer.draft.textarea.text(), "paste"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "paste"); + } + + #[test] + fn history_search_esc_resets_normal_history_navigation() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("oldest matching entry".to_string())); + composer + .history + .record_local_submission(HistoryEntry::new("newest entry".to_string())); + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['m', 'a', 't', 'c', 'h'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.draft.textarea.text(), "oldest matching entry"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!composer.history_search_active()); + assert!(composer.draft.textarea.is_empty()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(composer.draft.textarea.text(), "newest entry"); + } + + #[test] + fn history_search_no_match_restores_preview_but_keeps_search_open() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['z', 'z', 'z'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert!(composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "draft"); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f8c6c0d9c59a21699ae7a33c1600ac50f2c57556 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs @@ -0,0 +1,195 @@ +//! Pasted text edits the active history query while preserving the original draft. + +use super::super::super::chat_composer_history::HistoryEntry; +use super::super::ChatComposer; +use super::super::InputResult; +use super::super::LARGE_PASTE_CHAR_THRESHOLD; +use crate::app_event_sender::AppEventSender; +use crate::render::renderable::Renderable; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use tokio::sync::mpsc::unbounded_channel; + +fn composer_with_history() -> ChatComposer { + let (tx, _rx) = unbounded_channel(); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + for entry in ["git status", "git log"] { + composer + .history + .record_local_submission(HistoryEntry::new(entry.to_string())); + } + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer +} + +fn render_composer(composer: &ChatComposer, width: u16) -> Terminal { + let mut terminal = Terminal::new(TestBackend::new(width, /*height*/ 5)).unwrap(); + terminal + .draw(|frame| composer.render(frame.area(), frame.buffer_mut())) + .unwrap(); + terminal +} + +#[test] +fn history_search_paste_appends_query_and_accepts_match() { + let mut composer = composer_with_history(); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(composer.handle_paste("git".to_string())); + assert_eq!(composer.draft.textarea.text(), "git log"); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(composer.handle_paste("status".to_string())); + assert_eq!( + composer.history_search.as_ref().unwrap().query, + "git status" + ); + assert_eq!(composer.draft.textarea.text(), "git status"); + + let terminal = render_composer(&composer, /*width*/ 70); + insta::assert_snapshot!("history_search_pasted_query", terminal.backend()); + + let (result, _) = composer.handle_key_event(KeyCode::Enter.into()); + assert!(matches!(result, InputResult::None)); + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "git status"); +} + +#[test] +fn history_search_large_paste_clamps_cursor() { + let mut composer = composer_with_history(); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + composer.handle_paste("x".repeat(usize::from(u16::MAX) + 1)); + + let terminal = render_composer(&composer, /*width*/ 80); + let cursor = composer.history_search_cursor_pos(terminal.size().unwrap().into()); + assert_eq!(cursor, Some((79, 4))); + insta::assert_snapshot!( + "history_search_large_paste_cursor", + format!("{}\nCursor: {cursor:?}", terminal.backend()) + ); +} + +#[test] +fn history_search_empty_paste_preserves_selected_match() { + let mut composer = composer_with_history(); + let reverse_search = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL); + composer.handle_key_event(reverse_search); + composer.handle_paste("git".to_string()); + assert_eq!(composer.draft.textarea.text(), "git log"); + composer.handle_key_event(reverse_search); + assert_eq!(composer.draft.textarea.text(), "git status"); + let selected_draft = composer.snapshot_draft(); + + for paste in ["", "\x1b[31m\x1b[0m"] { + composer.handle_paste(paste.to_string()); + assert_eq!(composer.snapshot_draft(), selected_draft); + } + + let terminal = render_composer(&composer, /*width*/ 70); + insta::assert_snapshot!("history_search_empty_paste", terminal.backend()); +} + +#[test] +fn history_search_paste_shows_separators_and_matches_original_query() { + let mut composer = composer_with_history(); + for entry in ["foobarbaz", "foo↵bar⇥baz"] { + composer + .history + .record_local_submission(HistoryEntry::new(entry.to_string())); + } + let query = "foo\nbar\tbaz"; + let reverse_search = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL); + composer.handle_key_event(reverse_search); + composer.handle_paste(query.to_string()); + assert_eq!(composer.history_search.as_ref().unwrap().query, query); + assert_eq!(composer.draft.textarea.text(), "draft"); + + let terminal = render_composer(&composer, /*width*/ 70); + let cursor = composer.history_search_cursor_pos(terminal.size().unwrap().into()); + assert_eq!(cursor, Some((31, 4))); + insta::assert_snapshot!( + "history_search_pasted_separators", + format!("{}\nCursor: {cursor:?}", terminal.backend()) + ); + + composer.handle_key_event(KeyCode::Esc.into()); + composer + .history + .record_local_submission(HistoryEntry::new(query.to_string())); + composer.handle_key_event(reverse_search); + composer.handle_paste(query.to_string()); + composer.handle_key_event(KeyCode::Enter.into()); + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), query); +} + +#[test] +fn history_search_paste_preserves_original_draft_on_miss_and_cancel() { + let mut composer = composer_with_history(); + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + composer.draft.textarea.set_cursor(/*pos*/ 2); + let original_draft = composer.snapshot_draft(); + + for cancel_key in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ] { + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + composer.handle_paste("git".to_string()); + assert_eq!(composer.draft.textarea.text(), "git log"); + + composer.handle_paste(" missing".to_string()); + assert_eq!(composer.snapshot_draft(), original_draft); + assert_eq!( + composer.history_search.as_ref().unwrap().query, + "git missing" + ); + + composer.handle_key_event(cancel_key); + assert!(!composer.history_search_active()); + assert_eq!(composer.snapshot_draft(), original_draft); + } +} + +#[test] +fn history_search_paste_uses_full_sanitized_text() { + let temp = tempfile::tempdir().unwrap(); + let image_path = temp.path().join("history.png"); + image::RgbaImage::new(/*width*/ 1, /*height*/ 1) + .save(&image_path) + .unwrap(); + let image_path = image_path.to_string_lossy().into_owned(); + let large_paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + + for (pasted, query) in [ + (image_path.clone(), image_path), + (large_paste.clone(), large_paste), + ( + "é\r\n中\r\x1b[31mtext\x1b[0m".to_string(), + "é\n中\ntext".to_string(), + ), + ] { + let mut composer = composer_with_history(); + composer + .history + .record_local_submission(HistoryEntry::new(query.clone())); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + composer.handle_paste(pasted); + + assert_eq!(composer.history_search.as_ref().unwrap().query, query); + assert_eq!(composer.draft.textarea.text(), query); + assert!(composer.attachments.local_image_paths().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); + assert!(composer.current_text_elements().is_empty()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs new file mode 100644 index 0000000000000000000000000000000000000000..eb348d49c13a0ce8fd7a6ba6a5ae1f2760ddf56c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/inline_input.rs @@ -0,0 +1,153 @@ +//! Embed the composer in question rows, resetting history navigation when drafts change. + +use super::*; + +impl ComposerDraft { + pub(in crate::bottom_pane) fn text_with_pending(&self) -> String { + ChatComposer::expand_pending_pastes( + &self.text, + self.text_elements.clone(), + &self.pending_pastes, + ) + .0 + } +} + +impl ChatComposer { + pub(in crate::bottom_pane) fn restore_inline_draft(&mut self, draft: ComposerDraft) { + self.history.reset_navigation(); + self.restore_draft(draft); + } + + pub(crate) fn inline_flash(&self) -> Option> { + self.footer + .flash + .as_ref() + .filter(|_| self.footer.flash_visible()) + .map(|flash| flash.line.clone()) + } + + pub(crate) fn reset_vim_mode(&mut self) { + self.vim_history = VimHistory::default(); + self.draft.textarea.enter_vim_insert_mode(); + } + + /// Refresh at traversal start without resetting recalled entries or pending lookups. + pub(crate) fn copy_history_for_key(&mut self, composer: &Self, key: KeyEvent) { + let recall = self.is_empty() + && !self.history.is_navigating() + && if self.draft.textarea.is_vim_normal_mode() { + self.vim_normal_keymap.move_up.is_pressed(key) + || self.vim_normal_keymap.move_down.is_pressed(key) + } else { + self.editor_keymap.move_up.is_pressed(key) + || self.editor_keymap.move_down.is_pressed(key) + }; + let search = + self.history_search_previous_keys.is_pressed(key) && !self.handles_key_as_editing(key); + if self.history_search.is_none() && (recall || search) { + self.history = composer.history.clone(); + self.history.reset_navigation(); + } + } + + /// Give modal editing precedence over a containing view's submit/navigation keys. + /// Up/Down remain available to inline choices; j/k keep their Vim editing behavior. + pub(crate) fn handles_key_as_editing(&self, key: KeyEvent) -> bool { + self.history_search.is_some() + || self.should_handle_vim_insert_escape(key) + || self.draft.textarea.is_vim_operator_pending() + || self.draft.textarea.wants_vim_search_key(key) + || (self.draft.textarea.is_vim_normal_mode() + && !matches!(key.code, KeyCode::Up | KeyCode::Down) + && !self.submit_keys.is_pressed(key) + && !self.queue_keys.is_pressed(key) + && (self.vim_normal_keymap.move_up.is_pressed(key) + || self.vim_normal_keymap.move_down.is_pressed(key))) + } + + /// Finish buffered typing before another answer takes over this editor. + pub(crate) fn flush_pending_input(&mut self) { + if let Some(text) = self.draft.paste_burst.flush_before_modified_input() { + self.handle_paste(text); + } + self.draft.paste_burst.clear_after_explicit_paste(); + } + + pub(crate) fn inline_input_height(&self, width: u16) -> u16 { + self.draft.textarea.desired_height(width.max(1)) + + u16::from(self.history_search.is_some() || self.draft.textarea.vim_query().is_some()) + + u16::from(self.footer.flash_visible()) + } + + pub(crate) fn inline_cursor_pos(&self, mut area: Rect) -> Option<(u16, u16)> { + area.height = area + .height + .saturating_sub(u16::from(self.footer.flash_visible())); + let query_area = Rect::new( + area.x, + area.bottom().saturating_sub(1), + area.width, + area.height.min(1), + ); + if let Some(query) = self.draft.textarea.vim_query() { + return query.cursor_pos(query_area); + } + if self.history_search.is_some() { + return self.history_search_query_cursor_pos(query_area); + } + self.draft + .textarea + .cursor_pos_with_state(area, *self.draft.textarea_state.borrow()) + } + + pub(crate) fn render_inline_input(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + ratatui::widgets::Clear.render(area, buf); + Block::default() + .style(Style::reset().patch(user_message_style())) + .render(area, buf); + let mut area = area; + if self.footer.flash_visible() + && let Some(flash) = &self.footer.flash + { + area.height = area.height.saturating_sub(1); + flash.line.clone().render( + Rect::new(area.x, area.bottom(), area.width, /*height*/ 1), + buf, + ); + } + if let Some(query) = self.draft.textarea.vim_query() { + area.height = area.height.saturating_sub(1); + query.render( + Rect::new(area.x, area.bottom(), area.width, /*height*/ 1), + buf, + ); + } else if let Some(line) = self.history_search_footer_line() { + area.height = area.height.saturating_sub(1); + line.render( + Rect::new(area.x, area.bottom(), area.width, /*height*/ 1), + buf, + ); + } + let mut state = self.draft.textarea_state.borrow_mut(); + let highlights: Vec<_> = self + .history_search_highlight_ranges() + .into_iter() + .chain(self.draft.textarea.vim_search_highlights()) + .map(|range| (range, Style::new().reversed().bold())) + .collect(); + self.draft.textarea.render_ref_styled_with_highlights( + area, + buf, + &mut state, + Style::default(), + &highlights, + ); + if self.draft.textarea.is_empty() { + Line::from(self.placeholder_text.as_str().dim()).render(area, buf); + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs new file mode 100644 index 0000000000000000000000000000000000000000..faf6d54fa9644b1b64434ecfb265ba88013f9d8f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs @@ -0,0 +1,41 @@ +//! Capture raw paste tabs before completion and submission shortcuts see them. + +use super::*; + +impl ChatComposer { + pub(super) fn handle_paste_tab(&mut self, key: KeyEvent, now: Instant) -> bool { + if key.code != KeyCode::Tab + || !key.modifiers.is_empty() + || self.draft.disable_paste_burst + || !self.draft.textarea.allows_paste_burst() + { + return false; + } + + self.handle_paste_burst_flush(now); + if self + .draft + .paste_burst + .append_control_char_if_active('\t', now) + { + return true; + } + + // Short non-ASCII prefixes are inserted directly, without a held first character. + if self + .draft + .paste_burst + .direct_insert_newline_should_insert(now) + { + self.draft + .paste_burst + .begin_with_retro_grabbed(String::new(), now); + return self + .draft + .paste_burst + .append_control_char_if_active('\t', now); + } + + false + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/paste_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/paste_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..de8002709f1dc20eb971514b7cb49e1e38456253 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/paste_tests.rs @@ -0,0 +1,147 @@ +//! Raw paste tabs remain draft text until the user explicitly submits or queues it. + +use super::tests::new_test_composer; +use super::*; +use pretty_assertions::assert_eq; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +#[test] +fn paste_burst_tabs_preserve_multiline_draft() { + for running in [false, true] { + for first_line in ["x", "界", "first line\n"] { + let (mut composer, _rx) = new_test_composer(); + composer.set_task_running(running); + let mut now = Instant::now(); + let payload = format!("{first_line}\t\tsecond line\n\tthird line\n"); + + for ch in payload.chars() { + let (result, _) = match ch { + '\t' => { + assert!(composer.handle_paste_tab(KeyEvent::from(KeyCode::Tab), now)); + (InputResult::None, true) + } + '\n' => composer.handle_submission_with_time(/*should_queue*/ false, now), + ch => composer + .handle_input_basic_with_time(KeyEvent::from(KeyCode::Char(ch)), now), + }; + assert_eq!(result, InputResult::None); + now += Duration::from_millis(/*millis*/ 1); + } + + composer.handle_paste_burst_flush(now + PasteBurst::recommended_active_flush_delay()); + assert_eq!(composer.current_text(), payload); + + if running && first_line == "first line\n" { + let mut terminal = Terminal::new(TestBackend::new(60, 8)).unwrap(); + terminal + .draw(|frame| composer.render(frame.area(), frame.buffer_mut())) + .unwrap(); + insta::assert_snapshot!("paste_burst_tab_indentation", terminal.backend()); + } + + let (result, _) = composer.handle_key_event(KeyEvent::from(KeyCode::Tab)); + let expected = if running { + InputResult::Queued { + text: payload.trim().to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + pending_pastes: Vec::new(), + } + } else { + InputResult::Submitted { + text: payload.trim().to_string(), + text_elements: Vec::new(), + } + }; + assert_eq!(result, expected); + } + } +} + +#[test] +fn paste_burst_tabs_refresh_idle_timeout() { + for first_char in ['x', '界'] { + let (mut composer, _rx) = new_test_composer(); + let mut now = Instant::now(); + composer.handle_input_basic_with_time(KeyEvent::from(KeyCode::Char(first_char)), now); + assert!(composer.handle_paste_tab(KeyEvent::from(KeyCode::Tab), now)); + + for _ in 0..3 { + now += PasteBurst::recommended_active_flush_delay() / 2; + assert!(composer.handle_paste_tab(KeyEvent::from(KeyCode::Tab), now)); + } + + composer.handle_paste_burst_flush(now + PasteBurst::recommended_active_flush_delay()); + assert_eq!(composer.current_text(), format!("{first_char}\t\t\t\t")); + assert!(!composer.handle_paste_tab( + KeyEvent::from(KeyCode::Tab), + now + PasteBurst::recommended_active_flush_delay() + )); + } +} + +#[test] +fn paste_burst_tab_does_not_accept_a_completion() { + let (mut composer, _rx) = new_test_composer(); + composer.insert_str("/"); + assert!(matches!(composer.popups.active, ActivePopup::Command(_))); + composer + .draft + .paste_burst + .begin_with_retro_grabbed("review this".to_string(), Instant::now()); + + let (result, _) = composer.handle_key_event(KeyEvent::from(KeyCode::Tab)); + + assert_eq!(result, InputResult::None); + let pasted = composer + .draft + .paste_burst + .flush_before_modified_input() + .unwrap(); + composer.handle_paste(pasted); + assert_eq!(composer.current_text(), "/review this\t"); +} + +#[test] +fn paste_burst_expired_before_tab_still_queues() { + let (mut composer, _rx) = new_test_composer(); + composer.set_task_running(/*running*/ true); + composer.handle_input_basic_with_time( + KeyEvent::from(KeyCode::Char('x')), + Instant::now() - Duration::from_secs(/*secs*/ 1), + ); + + let (result, _) = composer.handle_key_event(KeyEvent::from(KeyCode::Tab)); + + assert_eq!( + result, + InputResult::Queued { + text: "x".to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + pending_pastes: Vec::new(), + } + ); +} + +#[test] +fn paste_burst_modified_queue_binding_still_dispatches() { + let (mut composer, _rx) = new_test_composer(); + composer.set_task_running(/*running*/ true); + composer.queue_keys = vec![key_hint::ctrl(KeyCode::Char('q'))]; + composer.handle_input_basic_with_time(KeyEvent::from(KeyCode::Char('x')), Instant::now()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)); + + assert_eq!( + result, + InputResult::Queued { + text: "x".to_string(), + text_elements: Vec::new(), + action: QueuedInputAction::Plain, + pending_pastes: Vec::new(), + } + ); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs new file mode 100644 index 0000000000000000000000000000000000000000..da2dfd908b751c4b4e56e8789f196c9595f8914a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/popup_state.rs @@ -0,0 +1,129 @@ +//! Popup lifecycle state for the chat composer. +//! Tracks the single active popup plus dismissal/query state used to synchronize it. + +use crate::bottom_pane::command_popup::CommandPopup; +use crate::bottom_pane::file_search_popup::FileSearchPopup; +use crate::bottom_pane::mentions_v2::MentionV2Popup; +use crate::bottom_pane::skill_popup::SkillPopup; +use crate::bottom_pane::textarea::TextArea; +use std::ops::Range; + +/// One token occurrence whose autocomplete popup should remain hidden. +pub(super) struct DismissedToken { + /// Popup query text for the token, excluding its leading sigil. + query: String, + /// Exact token text, including its sigil, captured when the popup was dismissed. + token: String, + /// Zero-based ordinal among identical token strings in the draft at dismissal time. + occurrence: usize, +} + +impl DismissedToken { + /// Captures the stable identity of the token at `range`. + pub(super) fn new(textarea: &TextArea, range: Range, query: String) -> Self { + let text = textarea.text(); + let token = text[range.clone()].to_string(); + let occurrence = complete_token_occurrences_before(textarea, &token, range.start); + Self { + query, + token, + occurrence, + } + } + + /// Returns whether `range` identifies the same token occurrence in the current draft. + /// + /// Byte offsets may shift under offset-only edits, while the token text and its ordinal keep + /// later identical occurrences distinct. + pub(super) fn matches(&self, textarea: &TextArea, range: &Range, query: &str) -> bool { + let text = textarea.text(); + if self.query != query || text.get(range.clone()) != Some(self.token.as_str()) { + return false; + } + complete_token_occurrences_before(textarea, &self.token, range.start) == self.occurrence + } +} + +/// Counts complete editable-token occurrences before `before` in a single ordered pass. +/// +/// Whitespace and atomic element edges delimit occurrences; bare nested sigils do not. This keeps +/// dismissal identity aligned with the completion resolver without rescanning every element for +/// each match. +fn complete_token_occurrences_before(textarea: &TextArea, token: &str, before: usize) -> usize { + let text = textarea.text(); + let mut elements_ending_before_matches = textarea.text_element_ranges().peekable(); + let mut elements_starting_after_matches = textarea.text_element_ranges().peekable(); + text[..before] + .match_indices(token) + .filter(|(start, _)| { + let end = start + token.len(); + while elements_ending_before_matches + .peek() + .is_some_and(|element| element.end < *start) + { + elements_ending_before_matches.next(); + } + while elements_starting_after_matches + .peek() + .is_some_and(|element| element.start < end) + { + elements_starting_after_matches.next(); + } + let ends_at_boundary = text[end..].chars().next().is_none_or(char::is_whitespace) + || elements_starting_after_matches + .peek() + .is_some_and(|element| element.start == end); + let starts_at_boundary = *start == 0 + || text[..*start] + .chars() + .next_back() + .is_some_and(char::is_whitespace) + || elements_ending_before_matches + .peek() + .is_some_and(|element| element.end == *start); + starts_at_boundary && ends_at_boundary + }) + .count() +} + +#[derive(Default)] +pub(super) struct PopupState { + pub(super) active: ActivePopup, + pub(super) dismissed_command_token: Option, + pub(super) dismissed_file_token: Option, + pub(super) current_file_query: Option, + pub(super) dismissed_mention_token: Option, +} + +impl PopupState { + pub(super) fn active(&self) -> bool { + !matches!(self.active, ActivePopup::None) + } +} + +/// Popup state - at most one can be visible at any time. +#[derive(Default)] +pub(super) enum ActivePopup { + #[default] + None, + Command(CommandPopup), + File(FileSearchPopup), + Skill(SkillPopup), + MentionV2(MentionV2Popup), +} + +impl ActivePopup { + pub(super) fn required_height(&self, width: u16, footer_total_height: u16) -> u16 { + match self { + Self::None => footer_total_height, + Self::Command(popup) => popup.calculate_required_height(width), + Self::File(popup) => popup.calculate_required_height(), + Self::Skill(popup) => popup.calculate_required_height(width), + Self::MentionV2(popup) => popup.calculate_required_height(width), + } + } +} + +#[cfg(test)] +#[path = "popup_state_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/popup_state_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/popup_state_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..8ab143675d15e0dc44ac6f66f37876029ed29c2d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/popup_state_tests.rs @@ -0,0 +1,60 @@ +use super::*; +use crate::bottom_pane::textarea::TextArea; +use pretty_assertions::assert_eq; + +#[test] +fn dismissed_tokens_after_adjacent_elements_are_occurrence_scoped() { + let first_bound = "$bound1"; + let second_bound = "$bound2"; + let token = "$other"; + let text = format!("{first_bound}{token} {second_bound}{token}"); + let first_start = first_bound.len(); + let second_start = text.rfind(token).expect("second token"); + let second_bound_start = text.find(second_bound).expect("second bound mention"); + let mut textarea = TextArea::new(); + textarea.insert_str(&text); + textarea.add_element_range(0..first_start); + textarea.add_element_range(second_bound_start..second_bound_start + second_bound.len()); + let dismissed = DismissedToken::new( + &textarea, + first_start..first_start + token.len(), + "other".to_string(), + ); + + assert!(!dismissed.matches( + &textarea, + &(second_start..second_start + token.len()), + "other", + )); +} + +#[test] +fn nested_at_query_does_not_count_as_complete_dismissed_token() { + let text = "@ma@latest @ma"; + let second_start = text.rfind("@ma").expect("second token"); + let mut textarea = TextArea::new(); + textarea.insert_str(text); + + assert_eq!( + complete_token_occurrences_before(&textarea, "@ma", second_start), + 0 + ); +} + +#[test] +fn non_sigil_elements_delimit_dismissed_token_occurrences() { + let token = "$other"; + let first_element = "[one]"; + let text = format!("{token}{first_element} {token}"); + let second_start = text.rfind(token).expect("second token"); + let mut textarea = TextArea::new(); + textarea.insert_str(&text); + textarea.add_element_range(token.len()..token.len() + first_element.len()); + let dismissed = DismissedToken::new(&textarea, 0..token.len(), "other".to_string()); + + assert!(!dismissed.matches( + &textarea, + &(second_start..second_start + token.len()), + "other", + )); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs new file mode 100644 index 0000000000000000000000000000000000000000..b0166f1fbfec347e0a9352d4c5338d6a35a1cf0c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -0,0 +1,76 @@ +//! Offline editing retains the draft in place. +//! Paste Enter handling is shared with normal submission so buffered newlines survive both paths. + +use super::*; + +impl ChatComposer { + /// Preserve Enter inside a paste burst without attempting submission. + pub(crate) fn handle_paste_enter(&mut self, now: Instant) -> bool { + let in_slash_context = self.slash_commands_enabled() + && !self.draft.is_bash_mode + && (matches!(self.popups.active, ActivePopup::Command(_)) + || self + .draft + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/')); + if !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() + && !in_slash_context + && self + .draft + .paste_burst + .append_control_char_if_active('\n', now) + { + return true; + } + if !in_slash_context + && !self.draft.disable_paste_burst + && self + .draft + .paste_burst + .newline_should_insert_instead_of_submit(now) + { + self.draft.textarea.insert_str("\n"); + self.draft.paste_burst.extend_window(now); + return true; + } + + false + } + + pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { + self.cancel_history_search(); + self.attachments.clear_remote_image_selection(); + self.popups.active = ActivePopup::None; + self.set_disable_paste_burst(/*disabled*/ true); + // Expand in reverse order so remaining ranges stay valid. Textarea replacement + // preserves the cursor and other elements, including images and mentions. + let pending_pastes = std::mem::take(&mut self.draft.pending_pastes); + if !pending_pastes.is_empty() { + for element in self.draft.textarea.text_elements().into_iter().rev() { + if let Some((_, text)) = pending_pastes.iter().find(|(placeholder, _)| { + element.placeholder(self.draft.textarea.text()) == Some(placeholder.as_str()) + }) { + self.draft + .textarea + .replace_range(element.byte_range.start..element.byte_range.end, text); + } + } + } + // Enter/Tab and configured submit bindings must never consume the draft offline. + // The basic editor reconciles attachments without invoking composer-level shortcuts. + if !matches!(key.code, KeyCode::Enter | KeyCode::Tab) + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + { + self.handle_input_basic(key); + } + } +} + +#[cfg(test)] +#[path = "reconnect_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_empty_paste.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_empty_paste.snap new file mode 100644 index 0000000000000000000000000000000000000000..3f02c08a36e8a1a7beedaac664581e5472bf7c2e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_empty_paste.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +assertion_line: 101 +expression: terminal.backend() +--- +" " +"› git status " +" " +" " +" reverse-i-search: git enter accept · esc cancel " diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_large_paste_cursor.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_large_paste_cursor.snap new file mode 100644 index 0000000000000000000000000000000000000000..5ff09c1b5518c85b07e9484f309a0d92f961e633 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_large_paste_cursor.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +assertion_line: 75 +expression: "format!(\"{}\\nCursor: {cursor:?}\", terminal.backend())" +--- +" " +"› draft " +" " +" " +" reverse-i-search: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +Cursor: Some((79, 4)) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_query.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_query.snap new file mode 100644 index 0000000000000000000000000000000000000000..44c520d77b04dbf58e3fd576d8f6d75472d7e17a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_query.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +assertion_line: 51 +expression: terminal.backend() +--- +" " +"› git status " +" " +" " +" reverse-i-search: git status enter accept · esc cancel " diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_separators.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_separators.snap new file mode 100644 index 0000000000000000000000000000000000000000..ed43df1994e7c08caed047e1f1d3d95bfd43a110 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_separators.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +assertion_line: 125 +expression: "format!(\"{}\\nCursor: {cursor:?}\", terminal.backend())" +--- +" " +"› draft " +" " +" " +" reverse-i-search: foo↵bar⇥baz no match " + +Cursor: Some((31, 4)) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__paste_tests__paste_burst_tab_indentation.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__paste_tests__paste_burst_tab_indentation.snap new file mode 100644 index 0000000000000000000000000000000000000000..f6d632ac8b0e1d7d79ff0883c83fb31a4aa9c83d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__paste_tests__paste_burst_tab_indentation.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer/paste_tests.rs +assertion_line: 38 +expression: terminal.backend() +--- +" " +"› first line " +" second line " +" third line " +" " +" " +" " +" tab to queue message 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__draft_composer.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__draft_composer.snap new file mode 100644 index 0000000000000000000000000000000000000000..732958b8e865fb2cb3819760cddfc986bd54c8b8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__draft_composer.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/chat_composer/snapshot_tests.rs +expression: rows +--- + +› Explore the night sky + + 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__voice_composer.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__voice_composer.snap new file mode 100644 index 0000000000000000000000000000000000000000..7b3a0148252a082047d9e7521916d7c144a4421e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__snapshot_tests__voice_composer.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer/snapshot_tests.rs +expression: rows +--- + + voice ● listening /voice mute /voice stop + mic ▁▃▄▅▆█ codex █▆▅▄▃▁ + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..9cd4e271100198ef5881948c8a29833ae49aad44 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history.rs @@ -0,0 +1,220 @@ +//! Bounded Vim undo/redo history for the complete composer-owned draft. +//! +//! A textarea does not own pending paste payloads, image attachments, or mention targets. Keeping +//! edit transactions here lets one snapshot restore those values together with visible text. Keys +//! and direct composer-owned changes share the same bounded transaction state. +//! Committed snapshots share a byte budget. One pending edit snapshot has its own matching cap, +//! so canceled commands cannot evict committed history. + +use std::collections::VecDeque; + +use super::super::textarea::VimPersistentState; +use super::ChatComposer; +use super::ComposerDraft; +use crate::key_hint::KeyBindingListExt; +use crossterm::event::KeyEvent; + +const MAX_VIM_UNDO_STEPS: usize = 64; +const MAX_VIM_UNDO_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Default)] +pub(super) struct VimHistory { + undo: VecDeque, + redo: VecDeque, + pending: Option, +} + +impl ComposerDraft { + fn vim_history_bytes(&self) -> usize { + let mut bytes = self.text.len() + + self.text_elements.len() + * std::mem::size_of::(); + for path in &self.local_image_paths { + bytes += path.as_os_str().len(); + } + for url in &self.remote_image_urls { + bytes += url.len(); + } + for binding in &self.mention_bindings { + bytes += binding.mention.len() + binding.path.len(); + } + for (placeholder, pasted) in &self.pending_pastes { + bytes += placeholder.len() + pasted.len(); + } + bytes + } +} + +impl VimHistory { + fn trim(&mut self) { + while self.undo.len() > MAX_VIM_UNDO_STEPS + || self + .undo + .iter() + .chain(self.redo.iter()) + .map(ComposerDraft::vim_history_bytes) + .sum::() + > MAX_VIM_UNDO_BYTES + { + if self.undo.pop_front().is_none() { + self.redo.pop_front(); + } + } + } +} + +impl ChatComposer { + /// Consume undo and redo before normal-mode commands, even when their history is empty. + pub(super) fn handle_vim_history_key(&mut self, event: KeyEvent) -> bool { + if !self.draft.textarea.is_vim_normal_mode() + || self.draft.textarea.is_vim_operator_pending() + || self.popups.active() + { + return false; + } + + let redo = self.vim_normal_keymap.redo.is_pressed(event); + let undo = self.vim_normal_keymap.undo.is_pressed(event); + if !undo && !redo { + return false; + } + + let snapshot = if redo { + self.vim_history.redo.pop_back() + } else { + self.vim_history.undo.pop_back() + }; + if let Some(snapshot) = snapshot { + let current = self.snapshot_draft(); + if redo { + self.vim_history.undo.push_back(current); + } else { + self.vim_history.redo.push_back(current); + } + let history = std::mem::take(&mut self.vim_history); + let mut vim_state = VimPersistentState::default(); + self.draft + .textarea + .swap_vim_persistent_state(&mut vim_state); + self.restore_draft(snapshot); + self.draft + .textarea + .swap_vim_persistent_state(&mut vim_state); + self.vim_history = history; + self.draft.textarea.enter_vim_normal_mode(); + self.vim_history.trim(); + } + true + } + + /// Snapshot only keys that can begin or complete a Vim edit transaction. + pub(super) fn begin_vim_edit(&mut self, event: KeyEvent) { + if !self.draft.textarea.is_vim_enabled() + || self.vim_history.pending.is_some() + || self.draft.textarea.is_vim_operator_pending() + { + return; + } + + if self.draft.textarea.is_vim_normal_mode() + && (self.vim_normal_keymap.move_left.is_pressed(event) + || self.vim_normal_keymap.move_right.is_pressed(event) + || self.vim_normal_keymap.move_up.is_pressed(event) + || self.vim_normal_keymap.move_down.is_pressed(event) + || self.vim_normal_keymap.move_word_forward.is_pressed(event) + || self.vim_normal_keymap.move_word_backward.is_pressed(event) + || self.vim_normal_keymap.move_word_end.is_pressed(event) + || self.vim_normal_keymap.move_line_start.is_pressed(event) + || self.vim_normal_keymap.move_line_end.is_pressed(event) + || self.vim_normal_keymap.find_forward.is_pressed(event) + || self.vim_normal_keymap.find_backward.is_pressed(event) + || self.vim_normal_keymap.jump_top.is_pressed(event) + || self.vim_normal_keymap.jump_bottom.is_pressed(event) + || self.draft.textarea.wants_vim_search_key(event) + || self.vim_normal_keymap.yank_line.is_pressed(event) + || self.vim_normal_keymap.start_yank_operator.is_pressed(event) + || self.vim_normal_keymap.cancel_operator.is_pressed(event)) + { + return; + } + + self.begin_vim_edit_transaction(); + } + + /// Start one standalone draft edit without splitting an active Vim command. + pub(super) fn begin_direct_vim_edit(&mut self) -> bool { + if !self.draft.textarea.is_vim_enabled() + || self.draft.textarea.is_vim_operator_pending() + || self.vim_history.pending.is_some() + || self.history_search.is_some() + { + return false; + } + + self.begin_vim_edit_transaction(); + if self.vim_history.pending.is_none() { + return false; + } + let mut vim_state = VimPersistentState::default(); + self.draft + .textarea + .swap_vim_persistent_state(&mut vim_state); + vim_state.commands.last_change.clear(); + self.draft + .textarea + .swap_vim_persistent_state(&mut vim_state); + true + } + + fn begin_vim_edit_transaction(&mut self) { + let visible_bytes = self.draft.textarea.text().len(); + let paste_bytes = self + .draft + .pending_pastes + .iter() + .map(|(placeholder, pasted)| placeholder.len() + pasted.len()) + .sum::(); + if visible_bytes.saturating_add(paste_bytes) > MAX_VIM_UNDO_BYTES { + self.vim_history = VimHistory::default(); + return; + } + + let snapshot = self.snapshot_draft(); + if snapshot.vim_history_bytes() <= MAX_VIM_UNDO_BYTES { + self.vim_history.pending = Some(snapshot); + self.vim_history.trim(); + } else { + self.vim_history = VimHistory::default(); + } + } + + /// Commit a complete normal-mode command or one insert-mode session. + pub(super) fn finish_vim_edit(&mut self) { + if !self.draft.textarea.is_vim_normal_mode() + || self.draft.textarea.is_vim_operator_pending() + { + return; + } + + let Some(snapshot) = self.vim_history.pending.take() else { + return; + }; + if snapshot.text == self.current_text() + && snapshot.text_elements == self.current_text_elements() + && snapshot.pending_pastes == self.draft.pending_pastes + && snapshot.local_image_paths == self.attachments.local_image_paths() + && snapshot.remote_image_urls == self.attachments.remote_image_urls() + && snapshot.mention_bindings == self.snapshot_mention_bindings() + { + return; + } + + self.vim_history.redo.clear(); + self.vim_history.undo.push_back(snapshot); + self.vim_history.trim(); + } +} + +#[cfg(test)] +#[path = "vim_history_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f296f2eb7994eeb919ef04b88110ffcc84b65edd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_history_tests.rs @@ -0,0 +1,665 @@ +//! Composer undo/redo transactions preserve rich draft data and active Vim commands. + +use std::path::PathBuf; + +use codex_config::types::KeybindingSpec; +use codex_config::types::KeybindingsSpec; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +use super::super::super::chat_composer_history::HistoryEntry; +use super::super::ChatComposer; +use super::super::InputResult; +use super::super::LARGE_PASTE_CHAR_THRESHOLD; +use super::super::PasteBurst; +use super::super::tests::new_test_composer; +use super::super::tests::snapshot_composer_state_with_width; +use crate::keymap::RuntimeKeymap; +use crate::slash_command::SlashCommand; + +fn vim_composer(text: &str) -> ChatComposer { + let (mut composer, _receiver) = new_test_composer(); + composer.set_disable_paste_burst(/*disabled*/ true); + composer.set_text_content(text.to_owned(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer +} + +fn keys(composer: &mut ChatComposer, keys: &str) { + for key in keys.chars() { + let code = if key == '\n' { + KeyCode::Enter + } else { + KeyCode::Char(key) + }; + let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } +} + +fn escape(composer: &mut ChatComposer) { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); +} + +fn ctrl_r(composer: &mut ChatComposer) { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); +} + +#[test] +fn undo_and_redo_group_complete_vim_edits() { + for (original, command, edited) in [ + ("one two", "cwX", "X two"), + ("abc", "iXYZ", "XYZabc"), + ("", "i/review", "/review"), + ("one two", "c/two\nX", "Xtwo"), + ("one two", "d/two\n", "two"), + ("abc-def", "df-", "def"), + ("abc-def", "cf-X", "Xdef"), + ] { + let mut composer = vim_composer(original); + keys(&mut composer, command); + if composer.popups.active() { + escape(&mut composer); + } + escape(&mut composer); + assert_eq!(composer.current_text(), edited); + assert_eq!(composer.vim_history.undo.len(), 1); + + keys(&mut composer, "u"); + assert_eq!( + (composer.current_text(), composer.cursor()), + (original.to_owned(), 0) + ); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), edited); + } +} + +#[test] +fn undo_preserves_repeat_and_pending_operator_bindings() { + let mut composer = vim_composer("alpha beta gamma"); + keys(&mut composer, "dw.u"); + assert_eq!(composer.current_text(), "beta gamma"); + keys(&mut composer, "u."); + assert_eq!(composer.current_text(), "beta gamma"); + + let mut composer = vim_composer("one two"); + let mut keymap = RuntimeKeymap::defaults(); + keymap.vim_operator.motion_word_forward = vec![crate::key_hint::plain(KeyCode::Char('u'))]; + composer.set_keymap_bindings(&keymap); + keys(&mut composer, "duu"); + assert_eq!(composer.current_text(), "one two"); +} + +#[test] +fn undo_and_redo_preserve_confirmed_vim_search() { + let mut composer = vim_composer("alpha beta alpha"); + keys(&mut composer, "/alpha\n0xun"); + assert_eq!( + (composer.current_text(), composer.cursor()), + ("alpha beta alpha".to_owned(), 11) + ); + + keys(&mut composer, "0"); + composer.handle_paste("Z".to_owned()); + keys(&mut composer, "n"); + assert_eq!( + (composer.current_text(), composer.cursor()), + ("Zalpha beta alpha".to_owned(), 12) + ); + keys(&mut composer, "u"); + ctrl_r(&mut composer); + keys(&mut composer, "n"); + assert_eq!( + (composer.current_text(), composer.cursor()), + ("Zalpha beta alpha".to_owned(), 1) + ); +} + +#[test] +fn externally_canceled_search_operator_finishes_the_undo_transaction() { + let mut composer = vim_composer("abc"); + keys(&mut composer, "xd/"); + assert!(composer.cancel_vim_search()); + keys(&mut composer, "uxu"); + assert_eq!(composer.current_text(), "abc"); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), "bc"); +} + +#[test] +fn normal_mode_explicit_paste_is_its_own_edit_and_replaces_stale_redo() { + let mut composer = vim_composer("abc"); + keys(&mut composer, "x"); + composer.handle_paste("Z".to_owned()); + assert_eq!(composer.current_text(), "Zbc"); + keys(&mut composer, "u"); + assert_eq!(composer.current_text(), "bc"); + keys(&mut composer, "u"); + assert_eq!(composer.current_text(), "abc"); + + composer.handle_paste("Z".to_owned()); + assert_eq!(composer.current_text(), "Zabc"); + assert!(composer.vim_history.redo.is_empty()); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), "Zabc"); + assert!(!composer.history_search_active()); + keys(&mut composer, "u"); + assert_eq!(composer.current_text(), "abc"); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), "Zabc"); +} + +#[test] +fn normal_mode_direct_insert_and_image_attachment_are_undoable() { + let mut composer = vim_composer("abc"); + keys(&mut composer, "xu"); + composer.insert_str("Z"); + assert_eq!(composer.current_text(), "Zabc"); + assert!(composer.vim_history.redo.is_empty()); + keys(&mut composer, "u"); + assert_eq!(composer.current_text(), "abc"); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), "Zabc"); + keys(&mut composer, "u"); + + let path = PathBuf::from("example.png"); + composer.attach_image(path.clone()); + let with_image = composer.draft_snapshot(); + assert_eq!(composer.local_image_paths(), vec![path]); + assert!(composer.vim_history.redo.is_empty()); + keys(&mut composer, "u"); + assert_eq!(composer.current_text(), "abc"); + assert!(composer.local_image_paths().is_empty()); + ctrl_r(&mut composer); + assert_eq!(composer.draft_snapshot(), with_image); + keys(&mut composer, "u\nABC"); + assert_eq!(composer.current_text(), "ABC"); + escape(&mut composer); + keys(&mut composer, "u"); + assert!(composer.is_empty()); + keys(&mut composer, "u"); + assert!(composer.is_empty()); +} + +#[test] +fn deleting_a_selected_remote_image_is_one_undoable_vim_edit() { + for key in [KeyCode::Delete, KeyCode::Backspace] { + let mut composer = vim_composer("abc"); + let url = "https://example.com/remote.png".to_owned(); + composer.set_remote_image_urls(vec![url.clone()]); + keys(&mut composer, "xu"); + assert_eq!(composer.vim_history.redo.len(), 1); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert!(composer.vim_history.pending.is_none()); + let _ = composer.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE)); + assert!(composer.remote_image_urls().is_empty()); + assert!(composer.vim_history.redo.is_empty()); + keys(&mut composer, "u"); + assert_eq!(composer.remote_image_urls(), vec![url]); + assert_eq!(composer.current_text(), "abc"); + ctrl_r(&mut composer); + assert!(composer.remote_image_urls().is_empty()); + assert_eq!(composer.current_text(), "abc"); + } +} + +#[test] +fn direct_edits_join_the_active_vim_insert_transaction() { + let mut composer = vim_composer("abc"); + composer.enable_vim_in_insert_mode(); + composer.handle_paste("ABC".to_owned()); + composer.attach_image(PathBuf::from("example.png")); + keys(&mut composer, "D"); + escape(&mut composer); + let edited = composer.draft_snapshot(); + assert_eq!(composer.vim_history.undo.len(), 1); + keys(&mut composer, "u"); + assert_eq!(composer.current_text(), "abc"); + assert!(composer.local_image_paths().is_empty()); + ctrl_r(&mut composer); + assert_eq!(composer.draft_snapshot(), edited); +} + +#[test] +fn vim_search_query_paste_does_not_invalidate_draft_redo() { + let mut composer = vim_composer("alpha beta alpha"); + keys(&mut composer, "xu/"); + composer.handle_paste("beta".to_owned()); + assert_eq!(composer.current_text(), "alpha beta alpha"); + assert_eq!( + composer.draft.textarea.vim_query().unwrap().editor.text(), + "beta" + ); + assert!(composer.vim_history.pending.is_none()); + + escape(&mut composer); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), "lpha beta alpha"); +} + +#[test] +fn vim_redo_keeps_its_binding_when_history_is_empty() { + let mut composer = vim_composer("abc"); + let before = composer.draft_snapshot(); + ctrl_r(&mut composer); + assert_eq!(composer.draft_snapshot(), before); + assert!(!composer.history_search_active()); + keys(&mut composer, "xu"); + assert_eq!(composer.current_text(), "abc"); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), "bc"); + assert!(!composer.history_search_active()); + let after = composer.draft_snapshot(); + ctrl_r(&mut composer); + assert_eq!(composer.draft_snapshot(), after); + assert!(composer.draft.textarea.is_vim_normal_mode()); + assert!(!composer.history_search_active()); + super::super::tests::snapshot_composer_state_with_width( + "vim_empty_redo_stays_normal", + /*width*/ 60, + /*enhanced_keys_supported*/ true, + |target| *target = composer, + ); + + let original = "a".repeat(super::MAX_VIM_UNDO_BYTES / 2 + 1); + let mut composer = vim_composer(&original); + keys(&mut composer, "xu"); + for command in ["d", "i"] { + keys(&mut composer, command); + escape(&mut composer); + assert_eq!(composer.vim_history.redo.len(), 1); + } + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), original[1..]); + assert!(!composer.history_search_active()); + + keys(&mut composer, "uiZ"); + escape(&mut composer); + assert!(composer.vim_history.redo.is_empty()); + keys(&mut composer, "x"); + assert_eq!(composer.vim_history.undo.len(), 1); + keys(&mut composer, "u"); + assert_eq!(composer.current_text(), format!("Z{original}")); +} + +#[test] +fn ctrl_r_searches_history_in_insert_and_non_vim_modes() { + let mut composer = vim_composer("draft"); + keys(&mut composer, "i"); + ctrl_r(&mut composer); + assert!(composer.history_search_active()); + escape(&mut composer); + assert_eq!(composer.draft.textarea.vim_mode_label(), Some("Insert")); + + composer.set_vim_enabled(/*enabled*/ false); + ctrl_r(&mut composer); + assert!(composer.history_search_active()); +} + +#[test] +fn removing_the_shell_prefix_is_its_own_undoable_edit() { + let mut composer = vim_composer(""); + keys(&mut composer, "!foo"); + escape(&mut composer); + keys(&mut composer, "0"); + let before = composer.draft_snapshot(); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "foo"); + keys(&mut composer, "u"); + assert_eq!(composer.draft_snapshot(), before); +} + +#[test] +fn canceled_reverse_history_preview_preserves_vim_undo_and_repeat() { + let mut composer = vim_composer("alpha beta alpha"); + let mut keymap = RuntimeKeymap::defaults(); + keymap.vim_normal.redo.clear(); + composer.set_keymap_bindings(&keymap); + composer + .history + .record_local_submission(HistoryEntry::new("archived prompt".to_owned())); + keys(&mut composer, "/alpha\n0x"); + ctrl_r(&mut composer); + keys(&mut composer, "archive"); + assert_eq!(composer.current_text(), "archived prompt"); + escape(&mut composer); + assert_eq!(composer.current_text(), "lpha beta alpha"); + keys(&mut composer, "u"); + assert_eq!(composer.current_text(), "alpha beta alpha"); + keys(&mut composer, "."); + assert_eq!(composer.current_text(), "lpha beta alpha"); + keys(&mut composer, "n"); + assert_eq!(composer.cursor(), 10); +} + +#[test] +fn canceled_history_preview_preserves_active_vim_commands() { + for query in ["", "archive", "missing"] { + for (command, edited) in [ + ("iX", "XYalpha beta"), + ("cwX", "XY beta"), + ("RX", "XYpha beta"), + ] { + let mut composer = vim_composer("alpha beta"); + composer + .history + .record_local_submission(HistoryEntry::new("archived prompt".to_owned())); + keys(&mut composer, command); + ctrl_r(&mut composer); + keys(&mut composer, query); + escape(&mut composer); + if command == "RX" { + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!(composer.current_text(), "alpha beta"); + keys(&mut composer, "X"); + } + keys(&mut composer, "Y"); + escape(&mut composer); + assert_eq!(composer.current_text(), edited); + keys(&mut composer, "u."); + assert_eq!(composer.current_text(), edited); + } + } +} + +#[test] +fn accepted_reverse_history_preview_starts_a_fresh_vim_edit_history() { + let mut composer = vim_composer("abc"); + composer + .history + .record_local_submission(HistoryEntry::new("archived prompt".to_owned())); + keys(&mut composer, "xiX"); + ctrl_r(&mut composer); + keys(&mut composer, "archive\n"); + assert_eq!(composer.current_text(), "archived prompt"); + assert!(!composer.history_search_active()); + escape(&mut composer); + keys(&mut composer, "u."); + assert_eq!(composer.current_text(), "archived prompt"); +} + +#[test] +fn undo_and_redo_use_configured_bindings_and_support_unbinding() { + let mut composer = vim_composer("abc"); + let mut keymap = RuntimeKeymap::defaults(); + keymap.vim_normal.undo = vec![crate::key_hint::plain(KeyCode::Char('z'))]; + keymap.vim_normal.redo = vec![crate::key_hint::plain(KeyCode::Char('v'))]; + composer.set_keymap_bindings(&keymap); + keys(&mut composer, "xu"); + assert_eq!(composer.current_text(), "bc"); + keys(&mut composer, "z"); + assert_eq!(composer.current_text(), "abc"); + keys(&mut composer, "v"); + assert_eq!(composer.current_text(), "bc"); + keys(&mut composer, "z"); + keymap.vim_normal.undo.clear(); + keymap.vim_normal.redo.clear(); + composer.set_keymap_bindings(&keymap); + keys(&mut composer, "xzv"); + assert_eq!(composer.current_text(), "bc"); + + let mut config = TuiKeymap::default(); + config.composer.history_search_previous = + Some(KeybindingsSpec::One(KeybindingSpec("u".to_owned()))); + composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap()); + keys(&mut composer, "u"); + assert!(composer.history_search_active()); + escape(&mut composer); + config.vim_normal.undo = Some(KeybindingsSpec::One(KeybindingSpec("u".to_owned()))); + composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap()); + keys(&mut composer, "u"); + assert!(!composer.history_search_active()); + + let mut composer = vim_composer("abc"); + keys(&mut composer, "xu"); + let mut config = TuiKeymap::default(); + config.composer.history_search_previous = + Some(KeybindingsSpec::One(KeybindingSpec("ctrl-r".to_owned()))); + composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap()); + ctrl_r(&mut composer); + assert!(composer.history_search_active()); + escape(&mut composer); + config.vim_normal.redo = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-r".to_owned()))); + composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap()); + ctrl_r(&mut composer); + assert!(!composer.history_search_active()); + assert_eq!(composer.current_text(), "bc"); + ctrl_r(&mut composer); + assert!(!composer.history_search_active()); + + let mut composer = vim_composer("abc"); + let mut config = TuiKeymap::default(); + config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec("u".to_owned()))); + composer.set_keymap_bindings(&RuntimeKeymap::from_config(&config).unwrap()); + assert_eq!( + composer + .handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)) + .0, + InputResult::Submitted { + text: "abc".to_owned(), + text_elements: Vec::new() + } + ); +} + +#[test] +fn undo_and_redo_restore_deleted_large_paste_payload() { + let mut composer = vim_composer("alpha "); + composer.draft.textarea.set_cursor("alpha ".len()); + keys(&mut composer, "i"); + let pasted = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + composer.handle_paste(pasted.clone()); + escape(&mut composer); + let before = composer.draft_snapshot(); + keys(&mut composer, "x"); + assert_eq!(composer.current_text(), "alpha "); + assert!(composer.draft.pending_pastes.is_empty()); + keys(&mut composer, "u"); + assert_eq!(composer.draft_snapshot(), before); + assert_eq!( + composer.current_text_with_pending(), + format!("alpha {pasted}") + ); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), "alpha "); + assert!(composer.draft.pending_pastes.is_empty()); +} + +#[test] +fn undo_and_redo_restore_deleted_image_attachment() { + let mut composer = vim_composer("alpha "); + composer.draft.textarea.set_cursor("alpha ".len()); + composer.attach_image(PathBuf::from("example.png")); + composer.draft.textarea.set_cursor("alpha ".len()); + let before = composer.draft_snapshot(); + keys(&mut composer, "RX"); + ctrl_r(&mut composer); + escape(&mut composer); + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!(composer.draft_snapshot(), before); + escape(&mut composer); + composer.draft.textarea.set_cursor("alpha ".len()); + keys(&mut composer, "x"); + assert_eq!(composer.current_text(), "alpha "); + assert!(composer.local_image_paths().is_empty()); + keys(&mut composer, "u"); + assert_eq!(composer.draft_snapshot(), before); + ctrl_r(&mut composer); + assert_eq!(composer.current_text(), "alpha "); + assert!(composer.local_image_paths().is_empty()); +} + +#[test] +fn find_jump_and_search_navigation_never_snapshot_the_draft() { + let mut composer = vim_composer("alpha beta\nalpha gamma"); + for key in ['f', 'F', 'G', '/', '?', 'n', 'N'] { + composer.begin_vim_edit(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); + assert!( + composer.vim_history.pending.is_none(), + "navigation binding {key:?} snapshotted the draft" + ); + } + let (code, modifiers) = composer + .vim_normal_keymap + .jump_top + .first() + .expect("native jump-top chord") + .parts(); + composer.begin_vim_edit(KeyEvent::new(code, modifiers)); + assert!(composer.vim_history.pending.is_none()); + + for command in ["llwbh0$", "fb", "Fa", "G", "/beta\nnN", "?alpha\n"] { + let text = "alpha beta\nalpha gamma"; + let mut composer = vim_composer(text); + for key in command.chars() { + keys(&mut composer, &key.to_string()); + assert!( + composer.vim_history.pending.is_none(), + "navigation command {command:?} snapshotted the draft" + ); + assert!(composer.vim_history.undo.is_empty()); + } + assert_eq!(composer.current_text(), text); + } + let mut keymap = RuntimeKeymap::defaults(); + keymap.vim_normal.redo.clear(); + composer.set_keymap_bindings(&keymap); + composer + .history + .record_local_submission(HistoryEntry::new("alpha archived".to_owned())); + ctrl_r(&mut composer); + keys(&mut composer, "alpha"); + assert_eq!(composer.current_text(), "alpha archived"); + assert!(composer.vim_history.pending.is_none()); + assert!(composer.vim_history.undo.is_empty()); + escape(&mut composer); + assert_eq!(composer.current_text(), "alpha beta\nalpha gamma"); +} + +#[test] +fn vim_replace_preserves_slash_completion_and_dispatch() { + let mut composer = vim_composer(""); + keys(&mut composer, "R/dif"); + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Tab)); + assert_eq!(composer.current_text(), "/diff "); + let (result, _) = composer.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(result, InputResult::Command(SlashCommand::Diff)); +} + +#[test] +fn vim_replace_backspace_restores_characters_in_new_slash_element() { + snapshot_composer_state_with_width( + "vim_replace_slash_recovery", + /*width*/ 100, + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_disable_paste_burst(/*disabled*/ true); + composer.set_text_content("abcde tail".into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + composer.draft.textarea.set_cursor(/*pos*/ 0); + keys(composer, "R/diff"); + assert_eq!(composer.draft.textarea.element_payloads(), vec!["/diff"]); + for expected in [("/dife tail", 4), ("/dide tail", 3)] { + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!( + (composer.current_text(), composer.current_cursor()), + (expected.0.to_string(), expected.1) + ); + } + }, + ); +} + +#[test] +fn vim_editing_modes_use_the_same_large_and_image_paste_handling() { + let temporary = tempfile::tempdir().expect("create image directory"); + let path = temporary.path().join("image.png"); + image::RgbaImage::new(/*width*/ 1, /*height*/ 1) + .save(&path) + .expect("write image"); + for pasted in [ + "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1), + path.to_string_lossy().into_owned(), + ] { + let mut insert = vim_composer("next"); + keys(&mut insert, "i"); + insert.handle_paste(pasted.clone()); + let mut replace = vim_composer("next"); + keys(&mut replace, "R"); + replace.handle_paste(pasted); + assert_eq!(replace.draft_snapshot(), insert.draft_snapshot()); + } +} + +#[test] +fn vim_replace_paste_bursts_preserve_overwritten_text_and_repeat() { + for pasted in ["XYZ", "日\u{3000}語"] { + let mut composer = vim_composer("abcd"); + keys(&mut composer, "R"); + composer.set_disable_paste_burst(/*disabled*/ false); + let now = std::time::Instant::now(); + for ch in pasted.chars() { + let _ = composer.handle_input_basic_with_time(KeyEvent::from(KeyCode::Char(ch)), now); + } + assert!(composer.is_in_paste_burst()); + composer.handle_paste_burst_flush(now + PasteBurst::recommended_active_flush_delay()); + assert_eq!(composer.current_text(), format!("{pasted}d")); + escape(&mut composer); + keys(&mut composer, "u."); + assert_eq!(composer.current_text(), format!("{pasted}d")); + } +} + +#[test] +fn vim_replace_completion_preserves_suffix() { + snapshot_composer_state_with_width( + "vim_replace_completion_suffix", + /*width*/ 100, + /*enhanced_keys_supported*/ true, + |composer| { + composer.set_disable_paste_burst(/*disabled*/ true); + for (text, cursor, command, path) in [ + ("@ma next", 3, "R", "src/main.rs"), + ("abc next", 0, "R@ma", "ma"), + ] { + composer.set_vim_enabled(/*enabled*/ true); + composer.set_text_content(text.into(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(cursor); + keys(composer, command); + composer.insert_selected_file_path(0.."@ma".len(), path); + composer.sync_popups(); + assert_eq!( + (composer.current_text(), composer.current_cursor()), + (format!("{path} next"), path.len() + 1) + ); + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!( + (composer.current_text(), composer.current_cursor()), + (format!("{path} next"), path.len()) + ); + } + }, + ); +} + +#[test] +fn external_editor_import_preserves_replace_backspace_recovery() { + let mut composer = vim_composer("old"); + keys(&mut composer, "RX"); + composer.apply_external_edit("abc".into()); + composer.draft.textarea.set_cursor(/*pos*/ 1); + let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + assert_eq!(composer.current_text(), "bc"); + for code in [KeyCode::Char('X'), KeyCode::Backspace] { + let _ = composer.handle_key_event(KeyEvent::from(code)); + } + let textarea = &composer.draft.textarea; + assert_eq!( + (textarea.text(), textarea.vim_mode_label()), + ("bc", Some("Replace")) + ); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..be602bdb3b6a6dc20fdb6b32c3a49d69e8575f1c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/vim_search_tests.rs @@ -0,0 +1,114 @@ +//! Fresh draft entry and composer-local Vim search remain separate input paths. + +use super::super::InputResult; +use super::super::tests::snapshot_composer_state_with_width; +use crate::keymap::KeyChordMatch; +use crate::keymap::KeyChordMatcher; +use crate::keymap::RuntimeKeymap; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use tokio::time::Instant; + +#[test] +fn fresh_vim_draft_records_typing_after_submission() { + snapshot_composer_state_with_width( + "vim_fresh_draft", + /*width*/ 40, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_disable_paste_burst(/*disabled*/ true); + for entry in [KeyCode::Char('i'), KeyCode::Esc] { + composer.set_text_content("sent".into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + composer.handle_key_event(entry.into()); + let (result, _) = composer.handle_key_event(KeyCode::Enter.into()); + assert!(matches!(result, InputResult::Submitted { .. })); + assert_eq!(composer.draft.textarea.vim_mode_label(), Some("Insert")); + for key in [KeyCode::Char('x'), KeyCode::Esc, KeyCode::Char('.')] { + composer.handle_key_event(key.into()); + } + assert_eq!(composer.current_text(), "xx"); + } + composer.handle_key_event(KeyCode::Enter.into()); + }, + ); +} + +#[test] +fn backspace_cancels_empty_vim_search() { + snapshot_composer_state_with_width( + "vim_search_cancelled", + /*width*/ 40, + /*enhanced_keys_supported*/ false, + |composer| { + for text in ["", "draft"] { + composer.set_text_content(text.into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + let cursor = composer.cursor(); + for prefix in ["/", "?", "d/", "d?", "c/", "c?", "y/", "y?"] { + for ch in prefix.chars() { + composer + .handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert!(composer.draft.textarea.vim_query().is_some()); + composer.handle_key_event(KeyCode::Backspace.into()); + assert!(!composer.draft.textarea.is_vim_operator_pending()); + assert!(composer.draft.textarea.is_vim_normal_mode()); + assert_eq!( + (composer.current_text(), composer.cursor()), + (text.into(), cursor) + ); + } + } + }, + ); +} + +#[test] +fn vim_search_query_edits_and_paste_preserve_the_draft() { + snapshot_composer_state_with_width( + "vim_composer_search", + /*width*/ 40, + /*enhanced_keys_supported*/ false, + |composer| { + composer.set_text_content("a g\u{301}g b हि gg".into(), Vec::new(), Vec::new()); + composer.set_vim_enabled(/*enabled*/ true); + composer.draft.textarea.set_cursor(/*pos*/ 0); + let config: TuiKeymap = toml::from_str("[vim_search]\nforward='g s'").unwrap(); + let keymap = RuntimeKeymap::from_config(&config).unwrap(); + composer.set_keymap_bindings(&keymap); + let mut matcher = KeyChordMatcher::default(); + for ch in "dgse\u{301}\u{8}gg".chars() { + let code = if ch == '\u{8}' { + KeyCode::Backspace + } else { + KeyCode::Char(ch) + }; + let event = KeyEvent::new(code, KeyModifiers::NONE); + let event = match matcher.advance( + event, + &keymap.chords, + composer.keymap_contexts(), + Instant::now(), + ) { + KeyChordMatch::PassThrough => event, + KeyChordMatch::Completed(event) => event, + KeyChordMatch::Pending(_) => continue, + other => panic!("unexpected chord result: {other:?}"), + }; + composer.handle_key_event(event); + } + composer.handle_paste("photo.png".into()); + let input = composer.draft.textarea.vim_query().unwrap(); + assert_eq!(input.editor.text(), "ggphoto.png"); + assert_eq!(composer.draft.textarea.text(), "a g\u{301}g b हि gg"); + assert!(composer.attachments.local_image_paths().is_empty()); + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + composer.handle_paste("ह".into()); + }, + ); +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history/search_batch.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history/search_batch.rs new file mode 100644 index 0000000000000000000000000000000000000000..95db7b80cf2277d17fa6e798e1fbbe2de715d8b5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history/search_batch.rs @@ -0,0 +1,204 @@ +use super::ChatComposerHistory; +use super::HistoryEntry; +use super::HistorySearchDirection; +use super::HistorySearchResult; +use super::MAX_BATCH_READ_RETRIES; +use super::PendingHistorySearch; +use crate::app_event::AppEvent; +use crate::app_event::HistoryBatchEntryResponse; +use crate::app_event_sender::AppEventSender; +use codex_message_history::HistoryBatchCursor; + +impl ChatComposerHistory { + /// Applies a query-independent batch to the persistent cache and, when still applicable, + /// resumes the active reverse search. + pub(crate) fn on_batch_response( + &mut self, + log_id: u64, + cursor: HistoryBatchCursor, + entries: Vec, + next_older_cursor: Option, + app_event_tx: &AppEventSender, + ) -> Option { + if self.persistent_log_id != Some(log_id) { + return None; + } + + let entries: Vec<_> = entries + .into_iter() + .map(|response| { + let entry = response.entry.map(|text| { + HistoryEntry::new_with_at_mentions(text, self.at_mention_restore_enabled) + }); + if entry.is_some() { + self.fetched_history.insert(response.offset, entry.clone()); + } else { + self.fetched_history.entry(response.offset).or_insert(None); + } + (response.offset, entry) + }) + .collect(); + + let (boundary_if_exhausted, _) = self.pending_batch(cursor)?; + if let Some(search) = self.search.as_mut() { + search.next_older_cursor = next_older_cursor; + } + + for (offset, entry) in entries { + if let Some(entry) = entry + && self.search_matches(&entry) + && self.search_result_is_unique(&entry) + { + return Some(self.search_match(offset, entry)); + } + } + + let result = if let Some(next_cursor) = next_older_cursor { + self.advance_older_search_with_batches_from( + next_cursor, + boundary_if_exhausted, + app_event_tx, + ) + } else { + self.exhausted_search_result(HistorySearchDirection::Older, boundary_if_exhausted) + }; + Some(result) + } + + /// Retries a failed batch lookup up to a fixed limit without treating the failure as history + /// exhaustion. + pub(crate) fn on_batch_error( + &mut self, + log_id: u64, + cursor: HistoryBatchCursor, + app_event_tx: &AppEventSender, + ) -> Option { + if self.persistent_log_id != Some(log_id) { + return None; + } + + let (boundary_if_exhausted, read_failures) = self.pending_batch(cursor)?; + + if read_failures < MAX_BATCH_READ_RETRIES + && let Some(thread_id) = self.thread_id + { + if let Some(search) = self.search.as_mut() { + search.awaiting = Some(PendingHistorySearch::Batch { + cursor, + boundary_if_exhausted, + read_failures: read_failures + 1, + }); + } + app_event_tx.send(AppEvent::LookupMessageHistoryBatch { + thread_id, + cursor, + log_id, + }); + return Some(HistorySearchResult::Pending); + } + + Some(if boundary_if_exhausted { + if let Some(search) = self.search.as_mut() { + search.awaiting = None; + } + HistorySearchResult::AtBoundary + } else { + self.search = None; + HistorySearchResult::Unavailable + }) + } + + fn pending_batch(&self, cursor: HistoryBatchCursor) -> Option<(bool, u8)> { + let Some(PendingHistorySearch::Batch { + cursor: awaited_cursor, + boundary_if_exhausted, + read_failures, + }) = self.search.as_ref().and_then(|search| search.awaiting) + else { + return None; + }; + (awaited_cursor == cursor).then_some((boundary_if_exhausted, read_failures)) + } + + /// Switches an older search from the single newest-entry probe to bounded batch lookups. + /// + /// Keeping the first probe on the single-entry path avoids fetching a batch when the newest + /// persistent entry matches, while every later miss is amortized across a bounded batch. + pub(super) fn advance_older_search_after_entry_miss( + &mut self, + offset: usize, + boundary_if_exhausted: bool, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let Some(next_offset) = offset.checked_sub(1) else { + return self + .exhausted_search_result(HistorySearchDirection::Older, boundary_if_exhausted); + }; + self.advance_older_search_with_batches_from( + HistoryBatchCursor::new(next_offset), + boundary_if_exhausted, + app_event_tx, + ) + } + + /// Scans cached offsets from a batch cursor and requests the first uncached older range. + /// + /// The byte anchor remains usable only while scanning begins at its exact `end_offset`; moving + /// past cached entries creates an offset-only cursor because no validated byte boundary exists + /// for those intermediate positions. + fn advance_older_search_with_batches_from( + &mut self, + mut cursor: HistoryBatchCursor, + boundary_if_exhausted: bool, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let mut offset = cursor.end_offset(); + loop { + if let Some(entry) = self.entry_at_cached_offset(offset) { + if self.search_matches(&entry) && self.search_result_is_unique(&entry) { + return self.search_match(offset, entry); + } + } else if !self.fetched_history.contains_key(&offset) + && offset < self.persistent_entry_count + { + return self.request_older_search_batch( + cursor, + boundary_if_exhausted, + app_event_tx, + ); + } + + let Some(next_offset) = offset.checked_sub(1) else { + return self + .exhausted_search_result(HistorySearchDirection::Older, boundary_if_exhausted); + }; + offset = next_offset; + cursor = HistoryBatchCursor::new(offset); + } + } + + pub(super) fn request_older_search_batch( + &mut self, + cursor: HistoryBatchCursor, + boundary_if_exhausted: bool, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) else { + return self + .exhausted_search_result(HistorySearchDirection::Older, boundary_if_exhausted); + }; + if let Some(search) = self.search.as_mut() { + search.awaiting = Some(PendingHistorySearch::Batch { + cursor, + boundary_if_exhausted, + read_failures: 0, + }); + } + app_event_tx.send(AppEvent::LookupMessageHistoryBatch { + thread_id, + cursor, + log_id, + }); + HistorySearchResult::Pending + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history/search_batch_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history/search_batch_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..883b5df90cc093abd82f6f9eabf26e8c203d9bcf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history/search_batch_tests.rs @@ -0,0 +1,308 @@ +use pretty_assertions::assert_eq; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::mpsc::unbounded_channel; + +use super::*; +use crate::app_event::AppEvent; +use crate::app_event::HistoryBatchEntryResponse; + +fn thread_id(value: u8) -> ThreadId { + ThreadId::from_string(&format!("00000000-0000-0000-0000-{value:012}")) + .expect("thread id should parse") +} + +fn batch_entry(offset: usize, entry: &str) -> HistoryBatchEntryResponse { + HistoryBatchEntryResponse { + offset, + entry: Some(entry.to_string()), + } +} + +fn found(entry: &str) -> HistorySearchResult { + HistorySearchResult::Found(HistoryEntry::new(entry.to_string())) +} + +fn history( + entry_count: usize, +) -> ( + ChatComposerHistory, + AppEventSender, + UnboundedReceiver, +) { + let (tx, rx) = unbounded_channel(); + let tx = AppEventSender::new(tx); + let mut history = ChatComposerHistory::new(); + history.set_metadata(thread_id(/*value*/ 1), /*log_id*/ 42, entry_count); + (history, tx, rx) +} + +fn recv_batch_request(rx: &mut UnboundedReceiver) -> (HistoryBatchCursor, u64) { + let AppEvent::LookupMessageHistoryBatch { cursor, log_id, .. } = + rx.try_recv().expect("batch request") + else { + panic!("expected batch request"); + }; + (cursor, log_id) +} + +fn recv_entry_request(rx: &mut UnboundedReceiver) -> (usize, u64) { + let AppEvent::LookupMessageHistoryEntry { offset, log_id, .. } = + rx.try_recv().expect("entry request") + else { + panic!("expected entry request"); + }; + (offset, log_id) +} + +fn start_older_search( + history: &mut ChatComposerHistory, + tx: &AppEventSender, + rx: &mut UnboundedReceiver, + query: &str, + newest_offset: usize, +) -> HistoryBatchCursor { + assert_eq!( + history.search( + query, + HistorySearchDirection::Older, + /*restart*/ true, + tx + ), + HistorySearchResult::Pending + ); + let (offset, log_id) = recv_entry_request(rx); + assert_eq!((offset, log_id), (newest_offset, 42)); + assert_eq!( + history.on_entry_response(log_id, offset, Some("unrelated entry".to_string()), tx), + HistoryEntryResponse::Search(HistorySearchResult::Pending) + ); + let (cursor, _) = recv_batch_request(rx); + assert_eq!(cursor.end_offset(), newest_offset - 1); + cursor +} + +#[test] +fn search_batch_late_data_is_cache_only_after_cancel_or_query_edit() { + let (mut cancelled, tx, mut rx) = history(/*entry_count*/ 5); + start_older_search( + &mut cancelled, + &tx, + &mut rx, + "cached", + /*newest_offset*/ 4, + ); + cancelled.reset_search(); + assert_eq!( + cancelled.on_batch_response( + /*log_id*/ 42, + HistoryBatchCursor::new(/*end_offset*/ 3), + vec![batch_entry(/*offset*/ 3, "cached match")], + Some(HistoryBatchCursor::new(/*end_offset*/ 2)), + &tx, + ), + None + ); + assert_eq!( + cancelled.search( + "cached", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ), + found("cached match") + ); + assert!(rx.try_recv().is_err()); + + let (mut edited, tx, mut rx) = history(/*entry_count*/ 5); + start_older_search(&mut edited, &tx, &mut rx, "old", /*newest_offset*/ 4); + assert_eq!( + edited.search( + "new", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ), + HistorySearchResult::Pending + ); + let (offset, _) = recv_entry_request(&mut rx); + assert_eq!(offset, 3); + assert_eq!( + edited.on_batch_response( + /*log_id*/ 42, + HistoryBatchCursor::new(/*end_offset*/ 3), + vec![batch_entry(/*offset*/ 3, "old data")], + Some(HistoryBatchCursor::new(/*end_offset*/ 2)), + &tx, + ), + None + ); + assert_eq!( + edited.on_entry_response( + /*log_id*/ 42, + /*offset*/ 3, + Some("new current match".to_string()), + &tx, + ), + HistoryEntryResponse::Search(found("new current match")) + ); +} + +#[test] +fn search_batch_rejects_stale_thread_and_log_metadata() { + let (mut history, tx, mut rx) = history(/*entry_count*/ 5); + start_older_search( + &mut history, + &tx, + &mut rx, + "stale", + /*newest_offset*/ 4, + ); + history.set_metadata( + thread_id(/*value*/ 2), + /*log_id*/ 43, + /*entry_count*/ 5, + ); + assert_eq!( + history.on_batch_response( + /*log_id*/ 42, + HistoryBatchCursor::new(/*end_offset*/ 3), + vec![batch_entry(/*offset*/ 3, "stale match")], + Some(HistoryBatchCursor::new(/*end_offset*/ 2)), + &tx, + ), + None + ); + assert!(history.fetched_history.is_empty()); +} + +#[test] +fn search_batch_read_failure_stops_after_bounded_retries() { + let (mut history, tx, mut rx) = history(/*entry_count*/ 5); + let cursor = start_older_search( + &mut history, + &tx, + &mut rx, + "retry", + /*newest_offset*/ 4, + ); + + for _ in 0..MAX_BATCH_READ_RETRIES { + assert_eq!( + history.on_batch_error(/*log_id*/ 42, cursor, &tx), + Some(HistorySearchResult::Pending) + ); + let (retry_cursor, log_id) = recv_batch_request(&mut rx); + assert_eq!((retry_cursor, log_id), (cursor, 42)); + } + + assert_eq!( + history.on_batch_error(/*log_id*/ 42, cursor, &tx), + Some(HistorySearchResult::Unavailable) + ); + assert!(rx.try_recv().is_err()); + assert!(history.search.is_none()); + assert_eq!( + history.search( + "retry", + HistorySearchDirection::Older, + /*restart*/ false, + &tx, + ), + HistorySearchResult::Pending + ); + assert_eq!(recv_entry_request(&mut rx), (3, 42)); +} + +#[test] +fn search_batch_match_preserves_cursor_for_next_older_search() { + let (mut history, tx, mut rx) = history(/*entry_count*/ 7); + let cursor = start_older_search( + &mut history, + &tx, + &mut rx, + "needle", + /*newest_offset*/ 6, + ); + let continuation = HistoryBatchCursor::new(/*end_offset*/ 2); + + assert_eq!( + history.on_batch_response( + /*log_id*/ 42, + cursor, + vec![ + batch_entry(/*offset*/ 5, "unrelated newer"), + batch_entry(/*offset*/ 4, "needle first"), + batch_entry(/*offset*/ 3, "unrelated older"), + ], + Some(continuation), + &tx, + ), + Some(found("needle first")) + ); + + assert_eq!( + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx, + ), + HistorySearchResult::Pending + ); + let (requested_cursor, _) = recv_batch_request(&mut rx); + assert_eq!(requested_cursor, continuation); + assert!(rx.try_recv().is_err()); + + assert_eq!( + history.on_batch_response( + /*log_id*/ 42, + continuation, + vec![batch_entry(/*offset*/ 2, "needle second")], + /*next_older_cursor*/ None, + &tx, + ), + Some(found("needle second")) + ); +} + +#[test] +fn search_batch_absent_1024_uses_one_single_and_eight_batches() { + let (mut history, tx, mut rx) = history(/*entry_count*/ 1_024); + let mut cursor = start_older_search( + &mut history, + &tx, + &mut rx, + "absent", + /*newest_offset*/ 1_023, + ); + let mut batches = 0; + + loop { + let end_offset = cursor.end_offset(); + let start_offset = end_offset.saturating_sub(127); + let entries = (start_offset..=end_offset) + .rev() + .map(|offset| batch_entry(offset, "unrelated entry")) + .collect(); + let next_older_cursor = start_offset.checked_sub(1).map(HistoryBatchCursor::new); + let expected = if next_older_cursor.is_some() { + HistorySearchResult::Pending + } else { + HistorySearchResult::NotFound + }; + assert_eq!( + history.on_batch_response(/*log_id*/ 42, cursor, entries, next_older_cursor, &tx,), + Some(expected) + ); + batches += 1; + let Some(expected_cursor) = next_older_cursor else { + break; + }; + let (next, _) = recv_batch_request(&mut rx); + assert_eq!(next, expected_cursor); + cursor = next; + } + + assert_eq!((1 + batches, batches), (9, 8)); + assert!(rx.try_recv().is_err()); +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/candidate.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/candidate.rs new file mode 100644 index 0000000000000000000000000000000000000000..bc869a4c46aa413784b5bedcc92a40311c866d7c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/candidate.rs @@ -0,0 +1,84 @@ +use std::path::PathBuf; + +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::text::Span; + +const TAG_WIDTH: usize = "Plugin".len(); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Selection { + File(PathBuf), + Tool { + insert_text: String, + path: Option, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum MentionType { + Plugin, + Skill, + Task, + File, + Directory, +} + +impl MentionType { + pub(super) fn is_filesystem(self) -> bool { + matches!(self, Self::File | Self::Directory) + } + + pub(super) fn span(self, base_style: Style) -> Span<'static> { + let style = match self { + Self::Plugin => base_style.magenta(), + Self::Skill => base_style.dim(), + Self::Task => base_style.cyan(), + Self::File => base_style.cyan(), + Self::Directory => base_style, + }; + format!("{: &'static str { + match self { + Self::Plugin => "Plugin", + Self::Skill => "Skill", + Self::Task => "Task", + Self::File => "File", + Self::Directory => "Dir", + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Candidate { + pub(super) display_name: String, + pub(super) description: Option, + pub(super) search_terms: Vec, + pub(super) mention_type: MentionType, + pub(super) selection: Selection, +} + +#[derive(Clone, Debug)] +pub(super) struct SearchResult { + pub(super) display_name: String, + pub(super) description: Option, + pub(super) mention_type: MentionType, + pub(super) selection: Selection, + pub(super) match_indices: Option>, + pub(super) score: i32, +} + +impl Candidate { + pub(super) fn to_result(&self, match_indices: Option>, score: i32) -> SearchResult { + SearchResult { + display_name: self.display_name.clone(), + description: self.description.clone(), + mention_type: self.mention_type, + selection: self.selection.clone(), + match_indices, + score, + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/filter.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/filter.rs new file mode 100644 index 0000000000000000000000000000000000000000..8658708e9359042a517e8910f5dc8501aae2e987 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/filter.rs @@ -0,0 +1,111 @@ +use codex_file_search::FileMatch; +use codex_file_search::MatchType; +use codex_utils_fuzzy_match::fuzzy_match; + +use super::candidate::Candidate; +use super::candidate::MentionType; +use super::candidate::SearchResult; +use super::candidate::Selection; +use super::search_mode::SearchMode; + +pub(super) fn filtered_candidates( + candidates: &[Candidate], + file_matches: &[FileMatch], + query: &str, + search_mode: SearchMode, + show_file_matches: bool, +) -> Vec { + let filter = query.trim(); + let mut out = Vec::new(); + + for candidate in candidates { + if !search_mode.accepts(candidate.mention_type) { + continue; + } + if filter.is_empty() { + out.push(candidate.to_result(/*match_indices*/ None, /*score*/ 0)); + continue; + } + + if let Some((indices, score)) = best_tool_match(candidate, filter) { + out.push(candidate.to_result(indices, score)); + } + } + + if show_file_matches { + out.extend( + file_matches + .iter() + .map(file_match_to_row) + .filter(|candidate| search_mode.accepts(candidate.mention_type)), + ); + } + + sort_rows(&mut out, filter); + out +} + +fn best_tool_match(candidate: &Candidate, filter: &str) -> Option<(Option>, i32)> { + if let Some((indices, score)) = fuzzy_match(&candidate.display_name, filter) { + return Some((Some(indices), score)); + } + + candidate + .search_terms + .iter() + .filter(|term| *term != &candidate.display_name) + .filter_map(|term| fuzzy_match(term, filter).map(|(_indices, score)| score)) + .min() + .map(|score| (None, score)) +} + +fn sort_rows(rows: &mut [SearchResult], filter: &str) { + let type_order = |mention_type: MentionType| match mention_type { + MentionType::Plugin => 0, + MentionType::Skill => 1, + MentionType::Task => 2, + MentionType::File | MentionType::Directory => 3, + }; + + rows.sort_by(|a, b| { + if a.mention_type == MentionType::Task && b.mention_type == MentionType::Task { + return std::cmp::Ordering::Equal; + } + type_order(a.mention_type) + .cmp(&type_order(b.mention_type)) + .then_with(|| compare_within_rank(a, b, filter)) + .then_with(|| a.display_name.cmp(&b.display_name)) + }); +} + +fn compare_within_rank(a: &SearchResult, b: &SearchResult, filter: &str) -> std::cmp::Ordering { + if a.mention_type.is_filesystem() && b.mention_type.is_filesystem() { + return b.score.cmp(&a.score); + } + if filter.is_empty() { + return a.display_name.cmp(&b.display_name); + } + + a.match_indices + .is_none() + .cmp(&b.match_indices.is_none()) + .then_with(|| a.score.cmp(&b.score)) +} + +fn file_match_to_row(file_match: &FileMatch) -> SearchResult { + let mention_type = match file_match.match_type { + MatchType::File => MentionType::File, + MatchType::Directory => MentionType::Directory, + }; + SearchResult { + display_name: file_match.path.to_string_lossy().to_string(), + description: None, + mention_type, + selection: Selection::File(file_match.path.clone()), + match_indices: file_match + .indices + .as_ref() + .map(|indices| indices.iter().map(|idx| *idx as usize).collect()), + score: file_match.score as i32, + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/footer.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/footer.rs new file mode 100644 index 0000000000000000000000000000000000000000..6200083cfb135ec20b2bfc776ddcdb29911d0cc1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/footer.rs @@ -0,0 +1,81 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Widget; + +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; + +use super::search_mode::SearchMode; + +pub(super) fn render_footer(area: Rect, buf: &mut Buffer, search_mode: SearchMode) { + let right_line = search_mode_indicator_line(search_mode); + let right_width = right_line.width() as u16; + let gap = u16::from(right_width > 0); + let left_width = area.width.saturating_sub(right_width).saturating_sub(gap); + let left_line = + truncate_line_with_ellipsis_if_overflow(footer_hint_line(), left_width as usize); + left_line.render( + Rect { + x: area.x, + y: area.y, + width: left_width, + height: 1, + }, + buf, + ); + if right_width > 0 && right_width <= area.width { + right_line.render( + Rect { + x: area.x + area.width - right_width, + y: area.y, + width: right_width, + height: 1, + }, + buf, + ); + } +} + +fn footer_hint_line() -> Line<'static> { + Line::from(vec![ + key_hint::plain(KeyCode::Enter).into(), + " insert · ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " close · ".dim(), + key_hint::plain(KeyCode::Left).into(), + "/".dim(), + key_hint::plain(KeyCode::Right).into(), + " switch search modes".dim(), + ]) +} + +fn search_mode_indicator_line(active_search_mode: SearchMode) -> Line<'static> { + let modes = [ + SearchMode::Results, + SearchMode::FilesystemOnly, + SearchMode::Tools, + ]; + let mut spans = Vec::with_capacity(modes.len() * 2 - 1); + + for (index, search_mode) in modes.into_iter().enumerate() { + if index > 0 { + spans.push(" ".dim()); + } + + if search_mode == active_search_mode { + let label = format!("[{}]", search_mode.label()); + let span = match search_mode { + SearchMode::Results | SearchMode::FilesystemOnly => label.cyan().bold(), + SearchMode::Tools => label.magenta().bold(), + }; + spans.push(span); + } else { + spans.push(format!(" {} ", search_mode.label()).dim()); + } + } + + Line::from(spans) +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/mod.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca6e280cbcac8d12bee8adec58c2a4878212389c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/mod.rs @@ -0,0 +1,16 @@ +//! The unified mention popup used by default in the TUI. +//! +//! The `mentions_v2` feature flag remains temporarily as a rollback path: disabling it restores +//! the legacy split mention and file-search popups. + +mod candidate; +mod filter; +mod footer; +mod popup; +mod render; +mod search_catalog; +mod search_mode; + +pub(crate) use candidate::Selection as MentionV2Selection; +pub(crate) use popup::Popup as MentionV2Popup; +pub(crate) use search_catalog::build_search_catalog; diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/popup.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/popup.rs new file mode 100644 index 0000000000000000000000000000000000000000..7ebc8ebf07b9e0cf94b7d35d3ae1632fba4cd3ef --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/popup.rs @@ -0,0 +1,169 @@ +use codex_file_search::FileMatch; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::candidate::Candidate; +use super::candidate::SearchResult; +use super::candidate::Selection; +use super::filter::filtered_candidates; +use super::render::render_popup; +use super::search_mode::SearchMode; +use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; +use crate::bottom_pane::scroll_state::ScrollState; + +pub(crate) struct Popup { + query: String, + file_search: FileSearch, + candidates: Vec, + rows: Vec, + search_mode: SearchMode, + state: ScrollState, +} + +impl Popup { + pub(crate) fn new(candidates: Vec, query: &str) -> Self { + let mut file_search = FileSearch::default(); + file_search.set_query(query); + let mut popup = Self { + query: query.to_string(), + file_search, + candidates, + rows: Vec::new(), + search_mode: SearchMode::Results, + state: ScrollState::new(), + }; + popup.refresh_rows(); + popup + } + + pub(crate) fn set_candidates(&mut self, candidates: Vec) { + let selection = self.selected(); + self.candidates = candidates; + self.refresh_rows(); + if let Some(selection) = selection + && let Some(index) = self.rows.iter().position(|row| row.selection == selection) + { + self.state.selected_idx = Some(index); + self.state + .ensure_visible(self.rows.len(), MAX_POPUP_ROWS.min(self.rows.len())); + } + } + + pub(crate) fn set_query(&mut self, query: &str) { + if self.query == query { + return; + } + self.query = query.to_string(); + self.file_search.set_query(query); + self.refresh_rows(); + } + + pub(crate) fn set_file_matches(&mut self, query: &str, matches: Vec) { + self.file_search.set_matches(query, matches); + self.refresh_rows(); + } + + pub(crate) fn selected(&self) -> Option { + let idx = self.state.selected_idx?; + self.rows.get(idx).map(|row| row.selection.clone()) + } + + pub(crate) fn move_up(&mut self) { + let len = self.rows.len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn move_down(&mut self) { + let len = self.rows.len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn previous_search_mode(&mut self) { + self.search_mode = self.search_mode.previous(); + self.refresh_rows(); + } + + pub(crate) fn next_search_mode(&mut self) { + self.search_mode = self.search_mode.next(); + self.refresh_rows(); + } + + pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 { + let visible = self.rows.len().clamp(1, MAX_POPUP_ROWS); + (visible as u16).saturating_add(2) + } + + /// Rebuilds cached rows and keeps selection valid after search inputs change. + fn refresh_rows(&mut self) { + self.rows = filtered_candidates( + &self.candidates, + &self.file_search.matches, + &self.query, + self.search_mode, + self.file_search.should_show_matches(), + ); + let len = self.rows.len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } +} + +impl WidgetRef for Popup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + render_popup( + area, + buf, + &self.rows, + &self.state, + self.file_search.empty_message(), + self.search_mode, + ); + } +} + +#[derive(Default)] +struct FileSearch { + pending_query: String, + display_query: String, + waiting: bool, + matches: Vec, +} + +impl FileSearch { + fn set_query(&mut self, query: &str) { + if query.is_empty() { + self.pending_query.clear(); + self.display_query.clear(); + self.waiting = false; + self.matches.clear(); + } else if query != self.pending_query { + self.pending_query = query.to_string(); + self.waiting = true; + } + } + + fn set_matches(&mut self, query: &str, matches: Vec) { + if query != self.pending_query { + return; + } + + self.display_query = query.to_string(); + self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect(); + self.waiting = false; + } + + fn should_show_matches(&self) -> bool { + !self.matches.is_empty() + } + + fn empty_message(&self) -> &'static str { + if self.waiting { + "loading..." + } else { + "no matches" + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs new file mode 100644 index 0000000000000000000000000000000000000000..4d80a3590e24bac0a54fa84c428407ce09adbd1b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/render.rs @@ -0,0 +1,301 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Widget; + +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::style::accent_style; + +use super::candidate::MentionType; +use super::candidate::SearchResult; +use super::candidate::Selection; +use super::footer::render_footer; +use super::search_mode::SearchMode; +use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; +use crate::bottom_pane::scroll_state::ScrollState; + +pub(super) fn render_popup( + area: Rect, + buf: &mut Buffer, + rows: &[SearchResult], + state: &ScrollState, + empty_message: &str, + search_mode: SearchMode, +) { + let (list_area, hint_area) = if area.height > 2 { + let hint_area = Rect { + x: area.x, + y: area.y + area.height - 1, + width: area.width, + height: 1, + }; + let list_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: area.height - 2, + }; + (list_area, Some(hint_area)) + } else { + (area, None) + }; + + render_rows(list_area, buf, rows, state, empty_message); + + if let Some(hint_area) = hint_area { + let hint_area = Rect { + x: hint_area.x + 2, + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + render_footer(hint_area, buf, search_mode); + } +} + +fn render_rows( + area: Rect, + buf: &mut Buffer, + rows: &[SearchResult], + state: &ScrollState, + empty_message: &str, +) { + if area.height == 0 { + return; + } + if rows.is_empty() { + Line::from(vec![" ".into(), empty_message.italic()]).render(area, buf); + return; + } + + let visible_items = MAX_POPUP_ROWS + .min(rows.len()) + .min(area.height.max(1) as usize); + let mut start_idx = state.scroll_top.min(rows.len().saturating_sub(1)); + if let Some(sel) = state.selected_idx { + if sel < start_idx { + start_idx = sel; + } else if visible_items > 0 { + let bottom = start_idx + visible_items - 1; + if sel > bottom { + start_idx = sel + 1 - visible_items; + } + } + } + + let mut cur_y = area.y; + let primary_column_width = rows + .iter() + .skip(start_idx) + .take(visible_items) + .map(primary_text_width) + .max() + .unwrap_or(0); + for (idx, row) in rows.iter().enumerate().skip(start_idx).take(visible_items) { + if cur_y >= area.y + area.height { + break; + } + + let selected = Some(idx) == state.selected_idx; + let line = build_line(row, selected, area.width as usize, primary_column_width); + line.render( + Rect { + x: area.x, + y: cur_y, + width: area.width, + height: 1, + }, + buf, + ); + cur_y = cur_y.saturating_add(1); + } +} + +fn build_line( + row: &SearchResult, + selected: bool, + width: usize, + primary_column_width: usize, +) -> Line<'static> { + let base_style = Style::default(); + let dim_style = Style::default().dim(); + let tag = row.mention_type.span(base_style); + let tag_width = tag.width(); + let gutter = if selected { "> " } else { " " }; + let gutter_width = gutter.len(); + let content_width = + width.saturating_sub(gutter_width.saturating_add(tag_width).saturating_add(2)); + let content = truncate_line_with_ellipsis_if_overflow( + content_line(row, base_style, dim_style, primary_column_width), + content_width, + ); + let rendered_content_width = content.width(); + let mut spans = vec![gutter.into()]; + spans.extend(content.spans); + let padding = width.saturating_sub( + gutter_width + .saturating_add(rendered_content_width) + .saturating_add(tag_width), + ); + if padding > 0 { + spans.push(" ".repeat(padding).set_style(dim_style)); + } + spans.push(tag); + if selected { + let style = accent_style(); + spans.iter_mut().for_each(|span| span.style = style); + } + + Line::from(spans) +} + +fn content_line( + row: &SearchResult, + base_style: Style, + dim_style: Style, + primary_column_width: usize, +) -> Line<'static> { + let mut spans = Vec::new(); + spans.extend(primary_spans(row, base_style)); + if let Some(secondary) = secondary_line(row, base_style, dim_style) { + let padding = primary_column_width + .saturating_sub(primary_text_width(row)) + .saturating_add(2); + spans.push(" ".repeat(padding).set_style(dim_style)); + spans.extend(secondary.spans); + } + + Line::from(spans) +} + +fn primary_spans(row: &SearchResult, base_style: Style) -> Vec> { + if let Some(file_name) = file_name(row) { + let style = if row.mention_type == MentionType::File { + base_style.fg(Color::Cyan) + } else { + base_style + }; + return vec![file_name.to_string().set_style(style)]; + } + + let mut spans = Vec::with_capacity(row.display_name.len()); + let name_style = match row.mention_type { + MentionType::Plugin => base_style.magenta(), + MentionType::Skill => base_style.dim(), + MentionType::Task => base_style.cyan(), + MentionType::File | MentionType::Directory => base_style, + }; + if let Some(indices) = row.match_indices.as_ref() { + let mut idx_iter = indices.iter().peekable(); + for (char_idx, ch) in row.display_name.chars().enumerate() { + let mut style = name_style; + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + style = style.bold(); + } + spans.push(ch.to_string().set_style(style)); + } + } else { + spans.push(row.display_name.clone().set_style(name_style)); + } + + spans +} + +fn secondary_line( + row: &SearchResult, + base_style: Style, + dim_style: Style, +) -> Option> { + if file_name(row).is_some() { + let mut spans = path_spans(row, base_style); + if let Some(description) = row + .description + .as_deref() + .filter(|description| !description.is_empty()) + { + spans.push(" ".set_style(dim_style)); + spans.push(description.to_string().set_style(dim_style)); + } + return Some(Line::from(spans)); + } + + row.description + .as_deref() + .filter(|description| !description.is_empty()) + .map(|description| Line::from(description.to_string().set_style(dim_style))) +} + +fn path_spans(row: &SearchResult, base_style: Style) -> Vec> { + let mut spans = Vec::with_capacity(row.display_name.len()); + let file_name_start = file_name_start(row); + let path_style = base_style.dim(); + if file_name_start == 0 { + spans.push("./".set_style(path_style)); + } else if let Some(indices) = row.match_indices.as_ref() { + let mut idx_iter = indices.iter().peekable(); + for (char_idx, ch) in row.display_name.chars().enumerate().take(file_name_start) { + let mut style = path_style; + if idx_iter.peek().is_some_and(|next| **next == char_idx) { + idx_iter.next(); + style = style.bold(); + } + spans.push(ch.to_string().set_style(style)); + } + } else if file_name_start != usize::MAX { + let byte_start = row + .display_name + .char_indices() + .nth(file_name_start) + .map(|(idx, _)| idx) + .unwrap_or(row.display_name.len()); + spans.push( + row.display_name[..byte_start] + .to_string() + .set_style(path_style), + ); + } else { + spans.push(row.display_name.clone().set_style(base_style)); + } + spans +} + +fn primary_text_width(row: &SearchResult) -> usize { + file_name(row) + .map(|file_name| file_name.chars().count()) + .unwrap_or_else(|| row.display_name.chars().count()) +} + +fn file_name(row: &SearchResult) -> Option<&str> { + let file_name_start = file_name_start(row); + if file_name_start == usize::MAX { + return None; + } + if file_name_start == 0 { + return Some(&row.display_name); + } + + let byte_start = row + .display_name + .char_indices() + .nth(file_name_start) + .map(|(idx, _)| idx) + .unwrap_or(row.display_name.len()); + Some(&row.display_name[byte_start..]) +} + +fn file_name_start(row: &SearchResult) -> usize { + match row.selection { + Selection::File(_) if row.mention_type.is_filesystem() => row + .display_name + .rfind(['/', '\\']) + .map(|idx| row.display_name[..idx + 1].chars().count()) + .unwrap_or(0), + Selection::File(_) | Selection::Tool { .. } => usize::MAX, + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs new file mode 100644 index 0000000000000000000000000000000000000000..fdc6143f357e07538c230ded7d62cda2bbf4026d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/search_catalog.rs @@ -0,0 +1,255 @@ +use std::collections::HashSet; + +use codex_app_server_protocol::SkillMetadata; +use codex_plugin::PluginCapabilitySummary; + +use crate::skills_helpers::skill_description; +use crate::skills_helpers::skill_display_name; +use crate::task_mentions::MAX_TASK_TITLE_CHARS; +use crate::task_mentions::TaskMention; + +use super::candidate::Candidate; +use super::candidate::MentionType; +use super::candidate::Selection; + +pub(crate) fn build_search_catalog( + skills: Option<&[SkillMetadata]>, + plugins: Option<&[PluginCapabilitySummary]>, + tasks: &[TaskMention], +) -> Vec { + let plugin_ids: HashSet<_> = plugins + .into_iter() + .flatten() + .map(|plugin| plugin.config_name.as_str()) + .collect(); + let mut candidates = Vec::new(); + if let Some(skills) = skills { + // Keep skills visible when older servers omit plugin ownership. + candidates.extend( + skills + .iter() + .filter(|skill| { + !skill + .plugin_id + .as_deref() + .is_some_and(|plugin_id| plugin_ids.contains(plugin_id)) + }) + .map(skill_candidate), + ); + } + + if let Some(plugins) = plugins { + candidates.extend(plugins.iter().map(plugin_candidate)); + } + + candidates.extend(tasks.iter().map(|task| { + let title = task.title.split_whitespace().collect::>().join(" "); + let title: String = title.chars().take(MAX_TASK_TITLE_CHARS).collect(); + Candidate { + display_name: title.clone(), + description: (!task.cwd.is_empty()).then(|| task.cwd.clone()), + search_terms: vec![ + title.clone(), + task.cwd.chars().take(MAX_TASK_TITLE_CHARS).collect(), + task.snippet.chars().take(MAX_TASK_TITLE_CHARS).collect(), + ], + mention_type: MentionType::Task, + selection: Selection::Tool { + insert_text: format!("@{title}"), + path: Some(format!("thread://{}", task.thread_id)), + }, + } + })); + + candidates +} + +fn skill_candidate(skill: &SkillMetadata) -> Candidate { + let display_name = skill_display_name(skill); + let description = optional_skill_description(skill); + let skill_name = skill.name.clone(); + let search_terms = if display_name == skill.name { + vec![skill_name.clone()] + } else { + vec![skill_name.clone(), display_name.clone()] + }; + Candidate { + display_name, + description, + search_terms, + mention_type: MentionType::Skill, + selection: Selection::Tool { + insert_text: format!("${skill_name}"), + path: Some(skill.path.to_string_lossy().into_owned()), + }, + } +} + +fn plugin_candidate(plugin: &PluginCapabilitySummary) -> Candidate { + let (plugin_name, marketplace_name) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let mention_name = plugin_mention_name(plugin_name, plugin.display_name.as_str()); + let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()]; + if plugin.display_name != plugin_name { + search_terms.push(plugin.display_name.clone()); + } + if !marketplace_name.is_empty() { + search_terms.push(marketplace_name.to_string()); + } + + Candidate { + display_name: plugin.display_name.clone(), + description: plugin_description(plugin), + search_terms, + mention_type: MentionType::Plugin, + selection: Selection::Tool { + insert_text: format!("@{mention_name}"), + path: Some(format!("plugin://{}", plugin.config_name)), + }, + } +} + +fn plugin_mention_name(plugin_name: &str, display_name: &str) -> String { + let plugin_segments = split_plugin_name_segments(plugin_name); + let display_segments = split_display_name_segments(display_name); + + if plugin_segments.len() == display_segments.len() + && plugin_segments.iter().zip(&display_segments).all( + |((plugin_segment, _), display_segment)| { + plugin_segment.eq_ignore_ascii_case(display_segment.as_str()) + }, + ) + { + let mut result = String::new(); + for ((_, separator), display_segment) in plugin_segments.into_iter().zip(display_segments) { + result.push_str(display_segment.as_str()); + if let Some(separator) = separator { + result.push(separator); + } + } + return result; + } + + title_case_plugin_name(plugin_name) +} + +fn split_plugin_name_segments(plugin_name: &str) -> Vec<(String, Option)> { + let mut segments = Vec::new(); + let mut current = String::new(); + + for ch in plugin_name.chars() { + if matches!(ch, '-' | '_') { + if !current.is_empty() { + segments.push((std::mem::take(&mut current), Some(ch))); + } + } else { + current.push(ch); + } + } + + if !current.is_empty() { + segments.push((current, None)); + } + + segments +} + +fn split_display_name_segments(display_name: &str) -> Vec { + display_name + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|segment| !segment.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn title_case_plugin_name(plugin_name: &str) -> String { + let mut result = String::with_capacity(plugin_name.len()); + let mut capitalize_next = true; + + for ch in plugin_name.chars() { + if matches!(ch, '-' | '_') { + capitalize_next = true; + result.push(ch); + continue; + } + + if capitalize_next && ch.is_ascii_alphabetic() { + result.push(ch.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(ch); + capitalize_next = false; + } + } + + result +} + +fn plugin_description(plugin: &PluginCapabilitySummary) -> Option { + let capability_labels = plugin_capability_labels(plugin); + plugin.description.clone().or_else(|| { + Some(if capability_labels.is_empty() { + "Plugin".to_string() + } else { + format!("Plugin - {}", capability_labels.join(" - ")) + }) + }) +} + +fn plugin_capability_labels(plugin: &PluginCapabilitySummary) -> Vec { + let mut labels = Vec::new(); + if plugin.has_skills { + labels.push("skills".to_string()); + } + if !plugin.mcp_server_names.is_empty() { + let mcp_server_count = plugin.mcp_server_names.len(); + labels.push(if mcp_server_count == 1 { + "1 MCP server".to_string() + } else { + format!("{mcp_server_count} MCP servers") + }); + } + if !plugin.app_connector_ids.is_empty() { + let app_count = plugin.app_connector_ids.len(); + labels.push(if app_count == 1 { + "1 app".to_string() + } else { + format!("{app_count} apps") + }); + } + labels +} + +fn optional_skill_description(skill: &SkillMetadata) -> Option { + let description = skill_description(skill).trim(); + (!description.is_empty()).then(|| description.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn plugin_mention_name_uses_display_segments_when_they_match_plugin_name() { + assert_eq!( + plugin_mention_name("mcp-search", "MCP Search"), + "MCP-Search" + ); + assert_eq!( + plugin_mention_name("google_calendar", "Google Calendar"), + "Google_Calendar" + ); + } + + #[test] + fn plugin_mention_name_falls_back_to_title_cased_plugin_name() { + assert_eq!(plugin_mention_name("sample", "Sample Plugin"), "Sample"); + assert_eq!( + plugin_mention_name("browser-use", "Browser Use"), + "Browser-Use" + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mentions_v2/search_mode.rs b/codex-rs/tui/src/bottom_pane/mentions_v2/search_mode.rs new file mode 100644 index 0000000000000000000000000000000000000000..c95ebe7b0bbfdac210b8259eaf29e419582f4322 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/mentions_v2/search_mode.rs @@ -0,0 +1,44 @@ +use super::candidate::MentionType; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SearchMode { + Results, + FilesystemOnly, + Tools, +} + +impl SearchMode { + pub(super) fn previous(self) -> Self { + match self { + Self::Results => Self::Tools, + Self::FilesystemOnly => Self::Results, + Self::Tools => Self::FilesystemOnly, + } + } + + pub(super) fn next(self) -> Self { + match self { + Self::Results => Self::FilesystemOnly, + Self::FilesystemOnly => Self::Tools, + Self::Tools => Self::Results, + } + } + + pub(super) fn accepts(self, mention_type: MentionType) -> bool { + match self { + Self::Results => true, + Self::FilesystemOnly => { + matches!(mention_type, MentionType::File | MentionType::Directory) + } + Self::Tools => matches!(mention_type, MentionType::Plugin | MentionType::Skill), + } + } + + pub(super) fn label(self) -> &'static str { + match self { + Self::Results => "All Results", + Self::FilesystemOnly => "Filesystem Only", + Self::Tools => "Plugins", + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs new file mode 100644 index 0000000000000000000000000000000000000000..27d53229b6d7b505201effbd521f2657351631d8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -0,0 +1,363 @@ +use ratatui::layout::Rect; + +use super::DESIRED_SPACERS_BETWEEN_SECTIONS; +use super::RequestUserInputOverlay; + +pub(super) struct LayoutSections { + pub(super) progress_area: Rect, + pub(super) question_area: Rect, + // Wrapped question text lines to render in the question area. + pub(super) question_lines: Vec, + pub(super) options_area: Rect, + pub(super) notes_area: Rect, + // Number of footer rows (status + hints). + pub(super) footer_lines: u16, +} + +impl RequestUserInputOverlay { + /// Compute layout sections, collapsing notes and hints as space shrinks. + pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { + let has_options = self.has_options(); + let notes_visible = !has_options || self.notes_ui_visible(); + let footer_pref = self.footer_required_height(area.width); + let notes_pref_height = self.notes_input_height(area.width); + let mut question_lines = self.wrapped_question_lines(area.width); + let question_height = question_lines.len() as u16; + + let layout = if has_options { + self.layout_with_options( + OptionsLayoutArgs { + available_height: area.height, + width: area.width, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + }, + &mut question_lines, + ) + } else { + self.layout_without_options( + area.height, + question_height, + notes_pref_height, + footer_pref, + &mut question_lines, + ) + }; + + let (progress_area, question_area, options_area, notes_area) = + self.build_layout_areas(area, layout); + + LayoutSections { + progress_area, + question_area, + question_lines, + options_area, + notes_area, + footer_lines: layout.footer_lines, + } + } + + /// Layout calculation when options are present. + fn layout_with_options( + &self, + args: OptionsLayoutArgs, + question_lines: &mut Vec, + ) -> LayoutPlan { + let OptionsLayoutArgs { + available_height, + width, + mut question_height, + notes_pref_height, + footer_pref, + notes_visible, + } = args; + let min_options_height = available_height.min(1); + let max_question_height = available_height.saturating_sub(min_options_height); + if question_height > max_question_height { + question_height = max_question_height; + question_lines.truncate(question_height as usize); + } + self.layout_with_options_normal( + OptionsNormalArgs { + available_height, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + }, + OptionsHeights { + preferred: self.options_preferred_height(width), + full: self.options_required_height(width), + }, + ) + } + + /// Normal layout for options case: allocate footer + progress first, and + /// only allocate notes (and its label) when explicitly visible. + fn layout_with_options_normal( + &self, + args: OptionsNormalArgs, + options: OptionsHeights, + ) -> LayoutPlan { + let OptionsNormalArgs { + available_height, + question_height, + notes_pref_height, + footer_pref, + notes_visible, + } = args; + let max_options_height = available_height.saturating_sub(question_height); + let min_options_height = max_options_height.min(1); + let mut options_height = options + .preferred + .min(max_options_height) + .max(min_options_height); + let used = question_height.saturating_add(options_height); + let mut remaining = available_height.saturating_sub(used); + + // When notes are hidden, prefer to reserve room for progress, footer, + // and spacers by shrinking the options window if needed. + let desired_spacers = if notes_visible { + // Notes already separate options from the footer, so only keep a + // single spacer between the question and options. + 1 + } else { + DESIRED_SPACERS_BETWEEN_SECTIONS + }; + let required_extra = footer_pref + .saturating_add(1) // progress line + .saturating_add(desired_spacers); + if remaining < required_extra { + let deficit = required_extra.saturating_sub(remaining); + let reducible = options_height.saturating_sub(min_options_height); + let reduce_by = deficit.min(reducible); + options_height = options_height.saturating_sub(reduce_by); + remaining = remaining.saturating_add(reduce_by); + } + + let mut progress_height = 0; + if remaining > 0 { + progress_height = 1; + remaining = remaining.saturating_sub(1); + } + + if !notes_visible { + let mut spacer_after_options = 0; + if remaining > footer_pref { + spacer_after_options = 1; + remaining = remaining.saturating_sub(1); + } + let footer_lines = footer_pref.min(remaining); + remaining = remaining.saturating_sub(footer_lines); + let mut spacer_after_question = 0; + if remaining > 0 { + spacer_after_question = 1; + remaining = remaining.saturating_sub(1); + } + let grow_by = remaining.min(options.full.saturating_sub(options_height)); + options_height = options_height.saturating_add(grow_by); + return LayoutPlan { + question_height, + progress_height, + spacer_after_question, + options_height, + spacer_after_options, + notes_height: 0, + footer_lines, + }; + } + + let footer_lines = footer_pref.min(remaining); + remaining = remaining.saturating_sub(footer_lines); + + // Prefer spacers before notes, then notes. + let mut spacer_after_question = 0; + if remaining > 0 { + spacer_after_question = 1; + remaining = remaining.saturating_sub(1); + } + let spacer_after_options = 0; + let mut notes_height = notes_pref_height.min(remaining); + remaining = remaining.saturating_sub(notes_height); + + notes_height = notes_height.saturating_add(remaining); + + LayoutPlan { + question_height, + progress_height, + spacer_after_question, + options_height, + spacer_after_options, + notes_height, + footer_lines, + } + } + + /// Layout calculation when no options are present. + /// + /// Handles both tight layout (when space is constrained) and normal layout + /// (when there's sufficient space for all elements). + /// + fn layout_without_options( + &self, + available_height: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + question_lines: &mut Vec, + ) -> LayoutPlan { + let required = question_height; + if required > available_height { + self.layout_without_options_tight(available_height, question_height, question_lines) + } else { + self.layout_without_options_normal( + available_height, + question_height, + notes_pref_height, + footer_pref, + ) + } + } + + /// Tight layout for no-options case: truncate question to fit available space. + fn layout_without_options_tight( + &self, + available_height: u16, + question_height: u16, + question_lines: &mut Vec, + ) -> LayoutPlan { + let max_question_height = available_height; + let adjusted_question_height = question_height.min(max_question_height); + question_lines.truncate(adjusted_question_height as usize); + + LayoutPlan { + question_height: adjusted_question_height, + progress_height: 0, + spacer_after_question: 0, + options_height: 0, + spacer_after_options: 0, + notes_height: 0, + footer_lines: 0, + } + } + + /// Normal layout for no-options case: allocate space for notes, footer, and progress. + fn layout_without_options_normal( + &self, + available_height: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + ) -> LayoutPlan { + let required = question_height; + let mut remaining = available_height.saturating_sub(required); + let mut notes_height = notes_pref_height.min(remaining); + remaining = remaining.saturating_sub(notes_height); + + let footer_lines = footer_pref.min(remaining); + remaining = remaining.saturating_sub(footer_lines); + + let mut progress_height = 0; + if remaining > 0 { + progress_height = 1; + remaining = remaining.saturating_sub(1); + } + + notes_height = notes_height.saturating_add(remaining); + + LayoutPlan { + question_height, + progress_height, + spacer_after_question: 0, + options_height: 0, + spacer_after_options: 0, + notes_height, + footer_lines, + } + } + + /// Build the final layout areas from computed heights. + fn build_layout_areas( + &self, + area: Rect, + heights: LayoutPlan, + ) -> ( + Rect, // progress_area + Rect, // question_area + Rect, // options_area + Rect, // notes_area + ) { + let mut cursor_y = area.y; + let progress_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: heights.progress_height, + }; + cursor_y = cursor_y.saturating_add(heights.progress_height); + let question_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: heights.question_height, + }; + cursor_y = cursor_y.saturating_add(heights.question_height); + cursor_y = cursor_y.saturating_add(heights.spacer_after_question); + + let options_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: heights.options_height, + }; + cursor_y = cursor_y.saturating_add(heights.options_height); + cursor_y = cursor_y.saturating_add(heights.spacer_after_options); + + let notes_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: heights.notes_height, + }; + + (progress_area, question_area, options_area, notes_area) + } +} + +#[derive(Clone, Copy, Debug)] +struct LayoutPlan { + progress_height: u16, + question_height: u16, + spacer_after_question: u16, + options_height: u16, + spacer_after_options: u16, + notes_height: u16, + footer_lines: u16, +} + +#[derive(Clone, Copy, Debug)] +struct OptionsLayoutArgs { + available_height: u16, + width: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + notes_visible: bool, +} + +#[derive(Clone, Copy, Debug)] +struct OptionsNormalArgs { + available_height: u16, + question_height: u16, + notes_pref_height: u16, + footer_pref: u16, + notes_visible: bool, +} + +#[derive(Clone, Copy, Debug)] +struct OptionsHeights { + preferred: u16, + full: u16, +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..06ac2fe6b25edb9d99e58c212890a67bb5e7e4d3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -0,0 +1,3907 @@ +//! Request-user-input overlay state machine. +//! +//! Core behaviors: +//! - Each question can be answered by selecting one option and/or providing notes. +//! - Notes are stored per question and appended as extra answers. +//! - Typing while focused on options jumps into notes to keep freeform input fast. +//! - The composer submit binding advances to the next question; the last question submits all answers. +//! - Freeform-only questions submit an empty answer list when empty. +use std::collections::HashMap; +use std::collections::VecDeque; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use crate::app::app_server_requests::ResolvedAppServerRequest; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +mod layout; +pub(super) mod render; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::ChatComposer; +use crate::bottom_pane::ChatComposerConfig; +use crate::bottom_pane::InputResult; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::selection_popup_common::GenericDisplayRow; +use crate::bottom_pane::selection_popup_common::measure_rows_height; +use crate::history_cell; +use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; +use crate::key_hint::ShortcutHint; +use crate::keymap::KeymapContext; +use crate::keymap::ListAction; +use crate::keymap::ListKeymap; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::Renderable; + +#[cfg(test)] +use crate::app_command::AppCommand as Op; +use codex_app_server_protocol::ToolRequestUserInputAnswer; +#[cfg(test)] +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; +use codex_protocol::user_input::TextElement; +use unicode_width::UnicodeWidthStr; + +const NOTES_PLACEHOLDER: &str = "Add notes"; +const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; +// Keep in sync with ChatComposer's minimum composer height. +const MIN_COMPOSER_HEIGHT: u16 = 3; +const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes"; +pub(super) const TIP_SEPARATOR: &str = " | "; +pub(super) const DESIRED_SPACERS_BETWEEN_SECTIONS: u16 = 2; +const OTHER_OPTION_LABEL: &str = "None of the above"; +const OTHER_OPTION_DESCRIPTION: &str = "Optionally, add details in notes (tab)."; +const UNANSWERED_CONFIRM_TITLE: &str = "Submit with unanswered questions?"; +const UNANSWERED_CONFIRM_GO_BACK: &str = "Go back"; +const UNANSWERED_CONFIRM_GO_BACK_DESC: &str = "Return to the first unanswered question."; +const UNANSWERED_CONFIRM_SUBMIT: &str = "Proceed"; +const UNANSWERED_CONFIRM_SUBMIT_DESC_SINGULAR: &str = "question"; +const UNANSWERED_CONFIRM_SUBMIT_DESC_PLURAL: &str = "questions"; +const AUTO_RESOLUTION_HIDDEN_GRACE: Duration = Duration::from_secs(/*secs*/ 60); +const AUTO_RESOLUTION_VISIBLE_COUNTDOWN: Duration = Duration::from_secs(/*secs*/ 60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Focus { + Options, + Notes, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AutoResolutionTiming { + Disabled, + HiddenGrace { remaining: Duration }, + VisibleCountdown { remaining: Duration }, + Due, +} + +fn format_auto_resolution_remaining(remaining: Duration) -> String { + let mut seconds = remaining.as_secs(); + if remaining.subsec_nanos() > 0 { + seconds = seconds.saturating_add(1); + } + if seconds < 60 { + return format!("{seconds}s"); + } + let minutes = seconds / 60; + let seconds = seconds % 60; + format!("{minutes}m {seconds:02}s") +} + +#[derive(Default, Clone, PartialEq)] +struct ComposerDraft { + text: String, + text_elements: Vec, + local_image_paths: Vec, + pending_pastes: Vec<(String, String)>, +} + +impl ComposerDraft { + fn text_with_pending(&self) -> String { + if self.pending_pastes.is_empty() { + return self.text.clone(); + } + debug_assert!( + !self.text_elements.is_empty(), + "pending pastes should always have matching text elements" + ); + let (expanded, _) = ChatComposer::expand_pending_pastes( + &self.text, + self.text_elements.clone(), + &self.pending_pastes, + ); + expanded + } +} + +struct AnswerState { + // Scrollable cursor state for option navigation/highlight. + options_state: ScrollState, + // Per-question notes draft. + draft: ComposerDraft, + // Whether the answer for this question has been explicitly submitted. + answer_committed: bool, + // Whether the notes UI has been explicitly opened for this question. + notes_visible: bool, +} + +#[derive(Clone, Debug)] +pub(super) struct FooterTip { + pub(super) text: String, + pub(super) highlight: bool, +} + +impl FooterTip { + fn new(text: impl Into) -> Self { + Self { + text: text.into(), + highlight: false, + } + } + + fn highlighted(text: impl Into) -> Self { + Self { + text: text.into(), + highlight: true, + } + } +} + +pub(crate) struct RequestUserInputOverlay { + app_event_tx: AppEventSender, + request: ToolRequestUserInputParams, + // Queue of incoming requests to process after the current one. + queue: VecDeque, + // Reuse the shared chat composer so notes/freeform answers match the + // primary input styling and behavior. + composer: ChatComposer, + // One entry per question: selection state plus a stored notes draft. + answers: Vec, + current_idx: usize, + focus: Focus, + done: bool, + pending_submission_draft: Option, + confirm_unanswered: Option, + request_started_at: Instant, + auto_resolution_snoozed: bool, + composer_submit_keys: Vec, + composer_submit_hint: Option, + interrupt_turn_keys: Vec, + interrupt_turn_hint: Option, + list_keymap: ListKeymap, +} + +impl RequestUserInputOverlay { + #[cfg(test)] + pub(crate) fn new( + request: ToolRequestUserInputParams, + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + ) -> Self { + Self::new_with_keymap( + request, + app_event_tx, + has_input_focus, + enhanced_keys_supported, + disable_paste_burst, + RuntimeKeymap::defaults(), + ) + } + + pub(crate) fn new_with_keymap( + request: ToolRequestUserInputParams, + app_event_tx: AppEventSender, + has_input_focus: bool, + enhanced_keys_supported: bool, + disable_paste_burst: bool, + keymap: RuntimeKeymap, + ) -> Self { + // Use the same composer widget, but disable popups/slash-commands and + // image-path attachment so it behaves like a focused notes field. + let mut composer = ChatComposer::new_with_config( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + ANSWER_PLACEHOLDER.to_string(), + disable_paste_burst, + ChatComposerConfig::plain_text(), + ); + composer.set_keymap_bindings(&keymap); + // The overlay renders its own footer hints, so keep the composer footer empty. + composer.set_footer_hint_override(Some(Vec::new())); + let mut overlay = Self { + app_event_tx, + request, + queue: VecDeque::new(), + composer, + answers: Vec::new(), + current_idx: 0, + focus: Focus::Options, + done: false, + pending_submission_draft: None, + confirm_unanswered: None, + request_started_at: Instant::now(), + auto_resolution_snoozed: false, + composer_submit_keys: keymap.composer.submit.clone(), + composer_submit_hint: keymap.primary_hint(KeymapContext::Composer, "submit"), + interrupt_turn_keys: keymap.chat.interrupt_turn.clone(), + interrupt_turn_hint: keymap.primary_hint(KeymapContext::Chat, "interrupt_turn"), + list_keymap: keymap.list, + }; + overlay.reset_for_request(); + overlay.ensure_focus_available(); + overlay.restore_current_draft(); + overlay + } + + fn current_index(&self) -> usize { + self.current_idx + } + + fn current_question(&self) -> Option<&ToolRequestUserInputQuestion> { + self.request.questions.get(self.current_index()) + } + + fn current_answer_mut(&mut self) -> Option<&mut AnswerState> { + let idx = self.current_index(); + self.answers.get_mut(idx) + } + + fn current_answer(&self) -> Option<&AnswerState> { + let idx = self.current_index(); + self.answers.get(idx) + } + + fn question_count(&self) -> usize { + self.request.questions.len() + } + + fn advance_queue_or_complete_at(&mut self, now: Instant) { + if let Some(next) = self.queue.pop_front() { + self.request = next; + self.request_started_at = now; + self.auto_resolution_snoozed = false; + self.reset_for_request(); + self.ensure_focus_available(); + self.restore_current_draft(); + } else { + self.done = true; + } + } + + fn snooze_auto_resolution(&mut self) { + if !self.request.is_blocking { + self.auto_resolution_snoozed = true; + } + } + + fn auto_resolution_timing_at(&self, now: Instant) -> AutoResolutionTiming { + // autoResolutionMs is deprecated; isBlocking now controls whether the + // request can auto-resolve using the TUI's fixed grace/countdown policy. + if self.request.is_blocking || self.auto_resolution_snoozed { + return AutoResolutionTiming::Disabled; + } + + let elapsed = now.saturating_duration_since(self.request_started_at); + if elapsed < AUTO_RESOLUTION_HIDDEN_GRACE { + return AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE.saturating_sub(elapsed), + }; + } + let visible_elapsed = elapsed.saturating_sub(AUTO_RESOLUTION_HIDDEN_GRACE); + if visible_elapsed < AUTO_RESOLUTION_VISIBLE_COUNTDOWN { + return AutoResolutionTiming::VisibleCountdown { + remaining: AUTO_RESOLUTION_VISIBLE_COUNTDOWN.saturating_sub(visible_elapsed), + }; + } + AutoResolutionTiming::Due + } + + fn auto_resolution_next_frame_delay_at(&self, now: Instant) -> Option { + match self.auto_resolution_timing_at(now) { + AutoResolutionTiming::Disabled => None, + AutoResolutionTiming::HiddenGrace { remaining } => Some(remaining), + AutoResolutionTiming::VisibleCountdown { remaining } => { + Some(remaining.min(Duration::from_secs(/*secs*/ 1))) + } + AutoResolutionTiming::Due => Some(Duration::ZERO), + } + } + + fn maybe_auto_resolve_at(&mut self, now: Instant) -> bool { + if !matches!( + self.auto_resolution_timing_at(now), + AutoResolutionTiming::Due + ) { + return false; + } + self.submit_empty_auto_resolution(now); + true + } + + fn auto_resolution_countdown_text_at(&self, now: Instant) -> Option { + match self.auto_resolution_timing_at(now) { + AutoResolutionTiming::VisibleCountdown { remaining } => Some(format!( + "auto-resolves in {}", + format_auto_resolution_remaining(remaining) + )), + AutoResolutionTiming::Disabled + | AutoResolutionTiming::HiddenGrace { .. } + | AutoResolutionTiming::Due => None, + } + } + + pub(super) fn progress_prefix_text(&self) -> String { + if self.question_count() > 0 { + let idx = self.current_index() + 1; + let total = self.question_count(); + let base = format!("Question {idx}/{total}"); + let unanswered = self.unanswered_count(); + if unanswered > 0 { + format!("{base} ({unanswered} unanswered)") + } else { + base + } + } else { + "No questions".to_string() + } + } + + fn has_options(&self) -> bool { + self.current_question() + .and_then(|question| question.options.as_ref()) + .is_some_and(|options| !options.is_empty()) + } + + fn options_len(&self) -> usize { + self.current_question() + .map(Self::options_len_for_question) + .unwrap_or(0) + } + + fn option_index_for_digit(&self, ch: char) -> Option { + if !self.has_options() { + return None; + } + let digit = ch.to_digit(10)?; + if digit == 0 { + return None; + } + let idx = (digit - 1) as usize; + (idx < self.options_len()).then_some(idx) + } + + fn selected_option_index(&self) -> Option { + if !self.has_options() { + return None; + } + self.current_answer() + .and_then(|answer| answer.options_state.selected_idx) + } + + fn notes_has_content(&self, idx: usize) -> bool { + if idx == self.current_index() { + !self.composer.current_text_with_pending().trim().is_empty() + } else { + !self.answers[idx].draft.text.trim().is_empty() + } + } + + pub(super) fn notes_ui_visible(&self) -> bool { + if !self.has_options() { + return true; + } + let idx = self.current_index(); + self.current_answer() + .is_some_and(|answer| answer.notes_visible || self.notes_has_content(idx)) + } + + pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec { + self.current_question() + .map(|q| { + textwrap::wrap(&q.question, width.max(1) as usize) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + }) + .unwrap_or_default() + } + + fn focus_is_notes(&self) -> bool { + matches!(self.focus, Focus::Notes) + } + + fn confirm_unanswered_active(&self) -> bool { + self.confirm_unanswered.is_some() + } + + pub(super) fn option_rows(&self) -> Vec { + self.current_question() + .and_then(|question| question.options.as_ref().map(|options| (question, options))) + .map(|(question, options)| { + let selected_idx = self + .current_answer() + .and_then(|answer| answer.options_state.selected_idx); + let mut rows = options + .iter() + .enumerate() + .map(|(idx, opt)| { + let selected = selected_idx.is_some_and(|sel| sel == idx); + let prefix = if selected { '›' } else { ' ' }; + let label = opt.label.as_str(); + let number = idx + 1; + let prefix_label = format!("{prefix} {number}. "); + let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str()); + GenericDisplayRow { + name: format!("{prefix_label}{label}"), + description: Some(opt.description.clone()), + wrap_indent: Some(wrap_indent), + ..Default::default() + } + }) + .collect::>(); + + if Self::other_option_enabled_for_question(question) { + let idx = options.len(); + let selected = selected_idx.is_some_and(|sel| sel == idx); + let prefix = if selected { '›' } else { ' ' }; + let number = idx + 1; + let prefix_label = format!("{prefix} {number}. "); + let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str()); + rows.push(GenericDisplayRow { + name: format!("{prefix_label}{OTHER_OPTION_LABEL}"), + description: Some(OTHER_OPTION_DESCRIPTION.to_string()), + wrap_indent: Some(wrap_indent), + ..Default::default() + }); + } + + rows + }) + .unwrap_or_default() + } + + pub(super) fn options_required_height(&self, width: u16) -> u16 { + if !self.has_options() { + return 0; + } + + let rows = self.option_rows(); + if rows.is_empty() { + return 1; + } + + let mut state = self + .current_answer() + .map(|answer| answer.options_state) + .unwrap_or_default(); + if state.selected_idx.is_none() { + state.selected_idx = Some(0); + } + + measure_rows_height(&rows, &state, rows.len(), width.max(1)) + } + + pub(super) fn options_preferred_height(&self, width: u16) -> u16 { + if !self.has_options() { + return 0; + } + + let rows = self.option_rows(); + if rows.is_empty() { + return 1; + } + + let mut state = self + .current_answer() + .map(|answer| answer.options_state) + .unwrap_or_default(); + if state.selected_idx.is_none() { + state.selected_idx = Some(0); + } + + measure_rows_height(&rows, &state, rows.len(), width.max(1)) + } + + fn capture_composer_draft(&self) -> ComposerDraft { + ComposerDraft { + text: self.composer.current_text(), + text_elements: self.composer.text_elements(), + local_image_paths: self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect(), + pending_pastes: self.composer.pending_pastes(), + } + } + + fn save_current_draft(&mut self) { + let draft = self.capture_composer_draft(); + let notes_empty = draft.text.trim().is_empty(); + if let Some(answer) = self.current_answer_mut() { + if answer.answer_committed && answer.draft != draft { + answer.answer_committed = false; + } + answer.draft = draft; + if !notes_empty { + answer.notes_visible = true; + } + } + } + + fn restore_current_draft(&mut self) { + self.composer + .set_placeholder_text(self.notes_placeholder().to_string()); + self.composer.set_footer_hint_override(Some(Vec::new())); + let Some(answer) = self.current_answer() else { + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + return; + }; + let draft = answer.draft.clone(); + self.composer + .set_text_content(draft.text, draft.text_elements, draft.local_image_paths); + self.composer.set_pending_pastes(draft.pending_pastes); + self.composer.move_cursor_to_end(); + } + + fn notes_placeholder(&self) -> &'static str { + if self.has_options() && self.selected_option_index().is_none() { + SELECT_OPTION_PLACEHOLDER + } else if self.has_options() { + NOTES_PLACEHOLDER + } else { + ANSWER_PLACEHOLDER + } + } + + fn sync_composer_placeholder(&mut self) { + self.composer + .set_placeholder_text(self.notes_placeholder().to_string()); + } + + fn clear_notes_draft(&mut self) { + if let Some(answer) = self.current_answer_mut() { + answer.draft = ComposerDraft::default(); + answer.answer_committed = false; + answer.notes_visible = true; + } + self.pending_submission_draft = None; + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + self.sync_composer_placeholder(); + } + + fn footer_tips(&self) -> Vec { + let mut tips = Vec::new(); + let notes_visible = self.notes_ui_visible(); + if self.has_options() { + if self.selected_option_index().is_some() && !notes_visible { + tips.push(FooterTip::highlighted("tab to add notes")); + } + if self.selected_option_index().is_some() && notes_visible { + tips.push(FooterTip::new("tab or esc to clear notes")); + } + } + + let question_count = self.question_count(); + let is_last_question = self.current_index().saturating_add(1) >= question_count; + let submit_key = if self.focus_is_notes() || !self.has_options() { + self.composer_submit_hint.map(ShortcutHint::display_label) + } else { + self.list_keymap + .primary_hint(ListAction::Accept) + .map(ShortcutHint::display_label) + }; + if let Some(submit_key) = submit_key { + let submit_tip = if question_count == 1 { + FooterTip::highlighted(format!("{submit_key} to submit answer")) + } else if is_last_question { + FooterTip::highlighted(format!("{submit_key} to submit all")) + } else { + FooterTip::new(format!("{submit_key} to submit answer")) + }; + tips.push(submit_tip); + } + if question_count > 1 { + if self.has_options() && !self.focus_is_notes() { + tips.push(FooterTip::new("←/→ to navigate questions")); + } else if !self.has_options() { + tips.push(FooterTip::new("ctrl + p / ctrl + n change question")); + } + } + if let Some(interrupt_key) = self.interrupt_turn_hint + && !(self.has_options() + && notes_visible + && interrupt_key == ShortcutHint::Single(crate::key_hint::plain(KeyCode::Esc))) + { + tips.push(FooterTip::new(format!( + "{} to interrupt", + interrupt_key.display_label() + ))); + } + tips + } + + pub(super) fn footer_tip_lines(&self, width: u16) -> Vec> { + self.wrap_footer_tips(width, self.footer_tips()) + } + + pub(super) fn footer_tip_lines_with_prefix( + &self, + width: u16, + prefix: Option, + ) -> Vec> { + let mut tips = Vec::new(); + if let Some(prefix) = prefix { + tips.push(prefix); + } + tips.extend(self.footer_tips()); + self.wrap_footer_tips(width, tips) + } + + fn wrap_footer_tips(&self, width: u16, tips: Vec) -> Vec> { + crate::footer_hint::wrap_hint_rows( + tips, + width, + UnicodeWidthStr::width(TIP_SEPARATOR), + |tip| UnicodeWidthStr::width(tip.text.as_str()), + ) + } + + pub(super) fn footer_required_height(&self, width: u16) -> u16 { + self.footer_tip_lines(width).len() as u16 + } + + /// Ensure the focus mode is valid for the current question. + fn ensure_focus_available(&mut self) { + if self.question_count() == 0 { + return; + } + if !self.has_options() { + self.focus = Focus::Notes; + if let Some(answer) = self.current_answer_mut() { + answer.notes_visible = true; + } + return; + } + if matches!(self.focus, Focus::Notes) && !self.notes_ui_visible() { + self.focus = Focus::Options; + self.sync_composer_placeholder(); + } + } + + /// Rebuild local answer state from the current request. + fn reset_for_request(&mut self) { + self.answers = self + .request + .questions + .iter() + .map(|question| { + let has_options = question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()); + let mut options_state = ScrollState::new(); + if has_options { + options_state.selected_idx = Some(0); + } + AnswerState { + options_state, + draft: ComposerDraft::default(), + answer_committed: false, + notes_visible: !has_options, + } + }) + .collect(); + + self.current_idx = 0; + self.focus = Focus::Options; + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.confirm_unanswered = None; + self.pending_submission_draft = None; + } + + fn options_len_for_question(question: &ToolRequestUserInputQuestion) -> usize { + let options_len = question + .options + .as_ref() + .map(std::vec::Vec::len) + .unwrap_or(0); + if Self::other_option_enabled_for_question(question) { + options_len + 1 + } else { + options_len + } + } + + fn other_option_enabled_for_question(question: &ToolRequestUserInputQuestion) -> bool { + question.is_other + && question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()) + } + + fn option_label_for_index( + question: &ToolRequestUserInputQuestion, + idx: usize, + ) -> Option { + let options = question.options.as_ref()?; + if idx < options.len() { + return options.get(idx).map(|opt| opt.label.clone()); + } + if idx == options.len() && Self::other_option_enabled_for_question(question) { + return Some(OTHER_OPTION_LABEL.to_string()); + } + None + } + + /// Move to the next/previous question, wrapping in either direction. + fn move_question(&mut self, next: bool) { + let len = self.question_count(); + if len == 0 { + return; + } + self.save_current_draft(); + let offset = if next { 1 } else { len.saturating_sub(1) }; + self.current_idx = (self.current_idx + offset) % len; + self.restore_current_draft(); + self.ensure_focus_available(); + } + + fn jump_to_question(&mut self, idx: usize) { + if idx >= self.question_count() { + return; + } + self.save_current_draft(); + self.current_idx = idx; + self.restore_current_draft(); + self.ensure_focus_available(); + } + + /// Synchronize selection state to the currently focused option. + fn select_current_option(&mut self, committed: bool) { + if !self.has_options() { + return; + } + let options_len = self.options_len(); + let updated = if let Some(answer) = self.current_answer_mut() { + answer.options_state.clamp_selection(options_len); + answer.answer_committed = committed; + true + } else { + false + }; + if updated { + self.sync_composer_placeholder(); + } + } + + /// Clear the current option selection and hide notes when empty. + fn clear_selection(&mut self) { + if !self.has_options() { + return; + } + if let Some(answer) = self.current_answer_mut() { + answer.options_state.reset(); + answer.draft = ComposerDraft::default(); + answer.answer_committed = false; + answer.notes_visible = false; + } + self.pending_submission_draft = None; + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + self.sync_composer_placeholder(); + } + + fn clear_notes_and_focus_options(&mut self) { + if !self.has_options() { + return; + } + if let Some(answer) = self.current_answer_mut() { + answer.draft = ComposerDraft::default(); + answer.answer_committed = false; + answer.notes_visible = false; + } + self.pending_submission_draft = None; + self.composer + .set_text_content(String::new(), Vec::new(), Vec::new()); + self.composer.move_cursor_to_end(); + self.focus = Focus::Options; + self.sync_composer_placeholder(); + } + + /// Ensure there is a selection before allowing notes entry. + fn ensure_selected_for_notes(&mut self) { + if let Some(answer) = self.current_answer_mut() { + answer.notes_visible = true; + } + self.sync_composer_placeholder(); + } + + /// Advance to next question, or submit when on the last one. + fn go_next_or_submit(&mut self) { + if self.current_index() + 1 >= self.question_count() { + self.save_current_draft(); + if self.unanswered_count() > 0 { + self.open_unanswered_confirmation(); + } else { + self.submit_answers(); + } + } else { + self.move_question(/*next*/ true); + } + } + + /// Build the response payload and dispatch it to the app. + fn submit_answers(&mut self) { + self.confirm_unanswered = None; + self.save_current_draft(); + let mut answers = HashMap::new(); + for (idx, question) in self.request.questions.iter().enumerate() { + let answer_state = &self.answers[idx]; + let options = question.options.as_ref(); + // For option questions we may still produce no selection. + let selected_idx = + if options.is_some_and(|opts| !opts.is_empty()) && answer_state.answer_committed { + answer_state.options_state.selected_idx + } else { + None + }; + // Notes are appended as extra answers. For freeform questions, only submit when + // the user explicitly committed the draft. + let notes = if answer_state.answer_committed { + answer_state.draft.text_with_pending().trim().to_string() + } else { + String::new() + }; + let selected_label = selected_idx + .and_then(|selected_idx| Self::option_label_for_index(question, selected_idx)); + let mut answer_list = selected_label.into_iter().collect::>(); + if !notes.is_empty() { + answer_list.push(format!("user_note: {notes}")); + } + answers.insert( + question.id.clone(), + ToolRequestUserInputAnswer { + answers: answer_list, + }, + ); + } + self.app_event_tx.user_input_answer( + self.request.turn_id.clone(), + ToolRequestUserInputResponse { + answers: answers.clone(), + }, + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::RequestUserInputResultCell { + questions: self.request.questions.clone(), + answers, + interrupted: false, + }, + ))); + self.advance_queue_or_complete_at(Instant::now()); + } + + fn submit_empty_auto_resolution(&mut self, now: Instant) { + self.confirm_unanswered = None; + let answers: HashMap = HashMap::new(); + self.app_event_tx.user_input_answer( + self.request.turn_id.clone(), + ToolRequestUserInputResponse { + answers: answers.clone(), + }, + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::RequestUserInputResultCell { + questions: self.request.questions.clone(), + answers, + interrupted: false, + }, + ))); + self.advance_queue_or_complete_at(now); + } + + fn dismiss_resolved_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + let ResolvedAppServerRequest::UserInput { call_id } = request else { + return false; + }; + + let queue_len = self.queue.len(); + self.queue + .retain(|queued_request| queued_request.item_id != *call_id); + if self.request.item_id == *call_id { + self.advance_queue_or_complete_at(Instant::now()); + return true; + } + + self.queue.len() != queue_len + } + + fn open_unanswered_confirmation(&mut self) { + let mut state = ScrollState::new(); + state.selected_idx = Some(0); + self.confirm_unanswered = Some(state); + } + + fn close_unanswered_confirmation(&mut self) { + self.confirm_unanswered = None; + } + + fn unanswered_question_count(&self) -> usize { + self.unanswered_count() + } + + fn unanswered_submit_description(&self) -> String { + let count = self.unanswered_question_count(); + let suffix = if count == 1 { + UNANSWERED_CONFIRM_SUBMIT_DESC_SINGULAR + } else { + UNANSWERED_CONFIRM_SUBMIT_DESC_PLURAL + }; + format!("Submit with {count} unanswered {suffix}.") + } + + fn first_unanswered_index(&self) -> Option { + let current_text = self.composer.current_text(); + self.request + .questions + .iter() + .enumerate() + .find(|(idx, _)| !self.is_question_answered(*idx, ¤t_text)) + .map(|(idx, _)| idx) + } + + fn unanswered_confirmation_rows(&self) -> Vec { + let selected = self + .confirm_unanswered + .as_ref() + .and_then(|state| state.selected_idx) + .unwrap_or(0); + let entries = [ + ( + UNANSWERED_CONFIRM_SUBMIT, + self.unanswered_submit_description(), + ), + ( + UNANSWERED_CONFIRM_GO_BACK, + UNANSWERED_CONFIRM_GO_BACK_DESC.to_string(), + ), + ]; + entries + .iter() + .enumerate() + .map(|(idx, (label, description))| { + let prefix = if idx == selected { '›' } else { ' ' }; + let number = idx + 1; + GenericDisplayRow { + name: format!("{prefix} {number}. {label}"), + description: Some(description.clone()), + ..Default::default() + } + }) + .collect() + } + + fn is_question_answered(&self, idx: usize, _current_text: &str) -> bool { + let Some(question) = self.request.questions.get(idx) else { + return false; + }; + let Some(answer) = self.answers.get(idx) else { + return false; + }; + let has_options = question + .options + .as_ref() + .is_some_and(|options| !options.is_empty()); + if has_options { + answer.options_state.selected_idx.is_some() && answer.answer_committed + } else { + answer.answer_committed + } + } + + /// Count questions that would submit an empty answer list. + fn unanswered_count(&self) -> usize { + let current_text = self.composer.current_text(); + self.request + .questions + .iter() + .enumerate() + .filter(|(idx, _question)| !self.is_question_answered(*idx, ¤t_text)) + .count() + } + + /// Compute the preferred notes input height for the current question. + fn notes_input_height(&self, width: u16) -> u16 { + let min_height = MIN_COMPOSER_HEIGHT; + self.composer + .desired_height(width.max(1)) + .clamp(min_height, min_height.saturating_add(5)) + } + + fn apply_submission_to_draft(&mut self, text: String, text_elements: Vec) { + let local_image_paths = self + .composer + .local_images() + .into_iter() + .map(|img| img.path) + .collect::>(); + if let Some(answer) = self.current_answer_mut() { + answer.draft = ComposerDraft { + text: text.clone(), + text_elements: text_elements.clone(), + local_image_paths: local_image_paths.clone(), + pending_pastes: Vec::new(), + }; + } + self.composer + .set_text_content(text, text_elements, local_image_paths); + self.composer.move_cursor_to_end(); + self.composer.set_footer_hint_override(Some(Vec::new())); + } + + fn apply_submission_draft(&mut self, draft: ComposerDraft) { + if let Some(answer) = self.current_answer_mut() { + answer.draft = draft.clone(); + } + self.composer + .set_text_content(draft.text, draft.text_elements, draft.local_image_paths); + self.composer.set_pending_pastes(draft.pending_pastes); + self.composer.move_cursor_to_end(); + self.composer.set_footer_hint_override(Some(Vec::new())); + } + + fn handle_composer_input_result(&mut self, result: InputResult) -> bool { + match result { + InputResult::Submitted { + text, + text_elements, + } + | InputResult::Queued { + text, + text_elements, + .. + } => { + if self.has_options() + && matches!(self.focus, Focus::Notes) + && !text.trim().is_empty() + { + let options_len = self.options_len(); + if let Some(answer) = self.current_answer_mut() { + answer.options_state.clamp_selection(options_len); + } + } + if self.has_options() { + if let Some(answer) = self.current_answer_mut() { + answer.answer_committed = true; + } + } else if let Some(answer) = self.current_answer_mut() { + answer.answer_committed = !text.trim().is_empty(); + } + let draft_override = self.pending_submission_draft.take(); + if let Some(draft) = draft_override { + self.apply_submission_draft(draft); + } else { + self.apply_submission_to_draft(text, text_elements); + } + self.go_next_or_submit(); + true + } + _ => false, + } + } + + fn handle_confirm_unanswered_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + let Some(state) = self.confirm_unanswered.as_mut() else { + return; + }; + + match key_event.code { + KeyCode::Esc | KeyCode::Backspace => { + self.close_unanswered_confirmation(); + if let Some(idx) = self.first_unanswered_index() { + self.jump_to_question(idx); + } + } + KeyCode::Up | KeyCode::Char('k') => { + state.move_up_wrap(/*len*/ 2); + } + KeyCode::Down | KeyCode::Char('j') => { + state.move_down_wrap(/*len*/ 2); + } + KeyCode::Enter => { + let selected = state.selected_idx.unwrap_or(0); + self.close_unanswered_confirmation(); + if selected == 0 { + self.submit_answers(); + } else if let Some(idx) = self.first_unanswered_index() { + self.jump_to_question(idx); + } + } + KeyCode::Char('1') | KeyCode::Char('2') => { + let idx = if matches!(key_event.code, KeyCode::Char('1')) { + 0 + } else { + 1 + }; + state.selected_idx = Some(idx); + } + _ => {} + } + } +} + +impl BottomPaneView for RequestUserInputOverlay { + fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet { + if self.confirm_unanswered_active() { + return crate::keymap::KeymapContextSet::default(); + } + if matches!(self.focus, Focus::Options) { + return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List) + .with(crate::keymap::KeymapContext::Chat); + } + self.composer + .keymap_contexts() + .with(crate::keymap::KeymapContext::Chat) + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn will_interrupt_turn_on_key_event(&self, key_event: KeyEvent) -> bool { + if KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL).is_press(key_event) { + return self.confirm_unanswered_active() + || !self.focus_is_notes() + || self.composer.current_text_with_pending().is_empty(); + } + + key_event.kind != KeyEventKind::Release + && !self.confirm_unanswered_active() + && !(matches!(key_event.code, KeyCode::Esc) + && self.has_options() + && self.notes_ui_visible()) + && self.interrupt_turn_keys.is_pressed(key_event) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + self.snooze_auto_resolution(); + + if self.confirm_unanswered_active() { + self.handle_confirm_unanswered_key_event(key_event); + return; + } + + if matches!(key_event.code, KeyCode::Esc) && self.has_options() && self.notes_ui_visible() { + self.clear_notes_and_focus_options(); + return; + } + + if self.interrupt_turn_keys.is_pressed(key_event) { + // TODO: Emit interrupted request_user_input results (including committed answers) + // once core supports persisting them reliably without follow-up turn issues. + self.app_event_tx.interrupt(); + self.done = true; + return; + } + + if self.focus_is_notes() && self.composer_submit_keys.is_pressed(key_event) { + self.ensure_selected_for_notes(); + self.pending_submission_draft = Some(self.capture_composer_draft()); + let (result, _) = self.composer.handle_key_event(key_event); + if !self.handle_composer_input_result(result) { + self.pending_submission_draft = None; + if self.has_options() { + self.select_current_option(/*committed*/ true); + } + self.go_next_or_submit(); + } + return; + } + + // Question navigation is always available. + match key_event { + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::PageUp, + modifiers: KeyModifiers::NONE, + .. + } => { + self.move_question(/*next*/ false); + return; + } + KeyEvent { + code: KeyCode::PageDown, + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_question(/*next*/ true); + return; + } + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } if self.has_options() && matches!(self.focus, Focus::Options) => { + self.move_question(/*next*/ false); + return; + } + _ if self.has_options() + && matches!(self.focus, Focus::Options) + && self.list_keymap.action_for(key_event) == Some(ListAction::MoveLeft) => + { + self.move_question(/*next*/ false); + return; + } + KeyEvent { + code: KeyCode::Char('l'), + modifiers: KeyModifiers::NONE, + .. + } + | KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + .. + } if self.has_options() && matches!(self.focus, Focus::Options) => { + self.move_question(/*next*/ true); + return; + } + _ if self.has_options() + && matches!(self.focus, Focus::Options) + && self.list_keymap.action_for(key_event) == Some(ListAction::MoveRight) => + { + self.move_question(/*next*/ true); + return; + } + _ => {} + } + + match self.focus { + Focus::Options => { + let options_len = self.options_len(); + // Keep selection synchronized as the user moves. + match (self.list_keymap.action_for(key_event), key_event.code) { + (Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => { + let moved = if let Some(answer) = self.current_answer_mut() { + answer.options_state.move_up_wrap(options_len); + answer.answer_committed = false; + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); + } + } + (Some(ListAction::MoveDown), _) | (_, KeyCode::Down | KeyCode::Char('j')) => { + let moved = if let Some(answer) = self.current_answer_mut() { + answer.options_state.move_down_wrap(options_len); + answer.answer_committed = false; + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); + } + } + (_, KeyCode::Char(' ')) => { + self.select_current_option(/*committed*/ true); + } + (_, KeyCode::Backspace | KeyCode::Delete) => { + self.clear_selection(); + } + (_, KeyCode::Tab) | (Some(ListAction::Accept), _) | (_, KeyCode::Enter) + if self.selected_option_index().is_some() + && (key_event.code == KeyCode::Tab + || self.current_question().is_some_and(|question| { + Self::other_option_enabled_for_question(question) + && self.selected_option_index() + == question.options.as_ref().map(Vec::len) + })) => + { + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); + } + (Some(ListAction::Accept), _) | (_, KeyCode::Enter) => { + let has_selection = self.selected_option_index().is_some(); + if has_selection { + self.select_current_option(/*committed*/ true); + } + self.go_next_or_submit(); + } + (_, KeyCode::Char(ch)) => { + if let Some(option_idx) = self.option_index_for_digit(ch) { + if let Some(answer) = self.current_answer_mut() { + answer.options_state.selected_idx = Some(option_idx); + } + self.select_current_option(/*committed*/ true); + self.go_next_or_submit(); + } + } + _ => {} + } + } + Focus::Notes => { + let notes_empty = self.composer.current_text_with_pending().trim().is_empty(); + if self.has_options() && matches!(key_event.code, KeyCode::Tab) { + self.clear_notes_and_focus_options(); + return; + } + if self.has_options() && matches!(key_event.code, KeyCode::Backspace) && notes_empty + { + self.save_current_draft(); + if let Some(answer) = self.current_answer_mut() { + answer.notes_visible = false; + } + self.focus = Focus::Options; + self.sync_composer_placeholder(); + return; + } + if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) { + let options_len = self.options_len(); + match key_event.code { + KeyCode::Up => { + let moved = if let Some(answer) = self.current_answer_mut() { + answer.options_state.move_up_wrap(options_len); + answer.answer_committed = false; + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); + } + } + KeyCode::Down => { + let moved = if let Some(answer) = self.current_answer_mut() { + answer.options_state.move_down_wrap(options_len); + answer.answer_committed = false; + true + } else { + false + }; + if moved { + self.sync_composer_placeholder(); + } + } + _ => {} + } + return; + } + self.ensure_selected_for_notes(); + if matches!( + key_event.code, + KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete + ) && let Some(answer) = self.current_answer_mut() + { + answer.answer_committed = false; + } + let before = self.capture_composer_draft(); + let (result, _) = self.composer.handle_key_event(key_event); + let submitted = self.handle_composer_input_result(result); + if !submitted { + let after = self.capture_composer_draft(); + if before != after + && let Some(answer) = self.current_answer_mut() + { + answer.answer_committed = false; + } + } + } + } + } + + fn terminal_title_requires_action(&self) -> bool { + true + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + if self.confirm_unanswered_active() { + self.close_unanswered_confirmation(); + // TODO: Emit interrupted request_user_input results (including committed answers) + // once core supports persisting them reliably without follow-up turn issues. + self.app_event_tx.interrupt(); + self.done = true; + return CancellationEvent::Handled; + } + if self.focus_is_notes() && !self.composer.current_text_with_pending().is_empty() { + self.clear_notes_draft(); + return CancellationEvent::Handled; + } + + // TODO: Emit interrupted request_user_input results (including committed answers) + // once core supports persisting them reliably without follow-up turn issues. + self.app_event_tx.interrupt(); + self.done = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + self.snooze_auto_resolution(); + if matches!(self.focus, Focus::Options) { + // Treat pastes the same as typing: switch into notes. + self.focus = Focus::Notes; + } + self.ensure_selected_for_notes(); + if let Some(answer) = self.current_answer_mut() { + answer.answer_committed = false; + } + self.composer.handle_paste(pasted) + } + + fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + + fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() + } + + fn pre_draw_tick(&mut self, now: Instant) -> bool { + self.maybe_auto_resolve_at(now) + } + + fn next_frame_delay(&self) -> Option { + self.auto_resolution_next_frame_delay_at(Instant::now()) + .into_iter() + .chain(self.composer.footer_flash_delay()) + .min() + } + + fn try_consume_user_input_request( + &mut self, + request: ToolRequestUserInputParams, + ) -> Option { + self.queue.push_back(request); + None + } + + fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { + self.dismiss_resolved_request(request) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::selection_popup_common::menu_surface_inset; + use crate::render::renderable::Renderable; + use codex_config::types::KeybindingSpec; + use codex_config::types::KeybindingsSpec; + use codex_config::types::TuiKeymap; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Color; + use std::collections::HashMap; + use std::time::Instant; + use tokio::sync::mpsc::unbounded_channel; + use unicode_width::UnicodeWidthStr; + + fn test_sender() -> ( + AppEventSender, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (tx_raw, rx) = unbounded_channel::(); + (AppEventSender::new(tx_raw), rx) + } + + fn expect_interrupt_only(rx: &mut tokio::sync::mpsc::UnboundedReceiver) { + let event = rx.try_recv().expect("expected interrupt AppEvent"); + let AppEvent::CodexOp(op) = event else { + panic!("expected CodexOp"); + }; + assert!(matches!(op, Op::Interrupt)); + assert!( + rx.try_recv().is_err(), + "unexpected AppEvents before interrupt completion" + ); + } + + fn question_with_options(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Choose an option.".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Option 1".to_string(), + description: "First choice.".to_string(), + }, + ToolRequestUserInputOption { + label: "Option 2".to_string(), + description: "Second choice.".to_string(), + }, + ToolRequestUserInputOption { + label: "Option 3".to_string(), + description: "Third choice.".to_string(), + }, + ]), + } + } + + fn question_with_options_and_other(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Choose an option.".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Option 1".to_string(), + description: "First choice.".to_string(), + }, + ToolRequestUserInputOption { + label: "Option 2".to_string(), + description: "Second choice.".to_string(), + }, + ToolRequestUserInputOption { + label: "Option 3".to_string(), + description: "Third choice.".to_string(), + }, + ]), + } + } + + fn question_with_wrapped_options(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Choose the next step for this task.".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Discuss a code change".to_string(), + description: + "Walk through a plan, then implement it together with careful checks." + .to_string(), + }, + ToolRequestUserInputOption { + label: "Run targeted tests".to_string(), + description: + "Pick the most relevant crate and validate the current behavior first." + .to_string(), + }, + ToolRequestUserInputOption { + label: "Review the diff".to_string(), + description: + "Summarize the changes and highlight the most important risks and gaps." + .to_string(), + }, + ]), + } + } + + fn question_with_very_long_option_text(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Choose one option.".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/unknown (Recommended when triaging long-running background work and status transitions)".to_string(), + description: "Keep async job statuses for progress tracking and include enough context for debugging retries, stale workers, and unexpected expiration paths.".to_string(), + }, + ToolRequestUserInputOption { + label: "Add a short status model".to_string(), + description: "Simpler labels with less detail for quick rollouts.".to_string(), + }, + ]), + } + } + + fn question_with_long_scroll_options(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: + "Choose one option; each hint is intentionally very long to test wrapped scrolling." + .to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Use Detailed Hint A (Recommended)".to_string(), + description: "Select this if you want a deliberately overextended explanatory hint that reads like a miniature specification, including context, rationale, expected behavior, and an explicit statement that this choice is mainly for testing how gracefully the interface wraps, truncates, and preserves readability under unusually verbose helper text conditions.".to_string(), + }, + ToolRequestUserInputOption { + label: "Use Detailed Hint B".to_string(), + description: "Select this if you want an equally verbose but differently phrased guidance block that emphasizes user-facing clarity, spacing tolerance, multiline wrapping, visual hierarchy interactions, and whether long descriptive metadata remains understandable when scanned quickly in a constrained layout where cognitive load is already high.".to_string(), + }, + ToolRequestUserInputOption { + label: "Use Detailed Hint C".to_string(), + description: "Select this when you specifically want to verify that navigating downward will keep the currently highlighted option visible, even when previous options consume many wrapped lines and would otherwise push the selection out of the viewport.".to_string(), + }, + ToolRequestUserInputOption { + label: "None of the above".to_string(), + description: + "Use this only if the previous long-form options do not apply.".to_string(), + }, + ]), + } + } + + fn question_without_options(id: &str, header: &str) -> ToolRequestUserInputQuestion { + ToolRequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Share details.".to_string(), + is_other: false, + is_secret: false, + options: None, + } + } + + fn request_event( + turn_id: &str, + questions: Vec, + ) -> ToolRequestUserInputParams { + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), + turn_id: turn_id.to_string(), + questions, + is_blocking: true, + auto_resolution_ms: None, + } + } + + fn request_event_with_auto_resolution( + turn_id: &str, + questions: Vec, + ) -> ToolRequestUserInputParams { + let mut request = request_event(turn_id, questions); + request.is_blocking = false; + request.auto_resolution_ms = Some(60_000); + request + } + + fn snapshot_buffer(buf: &Buffer) -> String { + let mut lines = Vec::new(); + for y in 0..buf.area().height { + let mut row = String::new(); + for x in 0..buf.area().width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(row); + } + lines.join("\n") + } + + fn render_snapshot(overlay: &RequestUserInputOverlay, area: Rect) -> String { + render_snapshot_at(overlay, area, Instant::now()) + } + + fn render_snapshot_at(overlay: &RequestUserInputOverlay, area: Rect, now: Instant) -> String { + let mut buf = Buffer::empty(area); + overlay.render_ui_at(area, &mut buf, now); + snapshot_buffer(&buf) + } + + #[test] + fn queued_requests_are_fifo() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "First")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.try_consume_user_input_request(request_event( + "turn-2", + vec![question_with_options("q2", "Second")], + )); + overlay.try_consume_user_input_request(request_event( + "turn-3", + vec![question_with_options("q3", "Third")], + )); + + overlay.submit_answers(); + assert_eq!(overlay.request.turn_id, "turn-2"); + + overlay.submit_answers(); + assert_eq!(overlay.request.turn_id, "turn-3"); + } + + #[test] + fn interrupt_discards_queued_requests_and_emits_interrupt() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "First")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-2".to_string(), + turn_id: "turn-2".to_string(), + questions: vec![question_with_options("q2", "Second")], + is_blocking: true, + auto_resolution_ms: None, + }); + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-3".to_string(), + turn_id: "turn-3".to_string(), + questions: vec![question_with_options("q3", "Third")], + is_blocking: true, + auto_resolution_ms: None, + }); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert!(overlay.done, "expected overlay to be done"); + expect_interrupt_only(&mut rx); + } + + #[test] + fn auto_resolution_absent_has_no_timer() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "First")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + + assert_eq!( + overlay.auto_resolution_timing_at(now), + AutoResolutionTiming::Disabled + ); + assert_eq!(overlay.auto_resolution_next_frame_delay_at(now), None); + assert_eq!(overlay.auto_resolution_countdown_text_at(now), None); + } + + #[test] + fn auto_resolution_uses_is_blocking_without_auto_resolution_ms() { + let (tx, mut rx) = test_sender(); + let mut request = request_event("turn-1", vec![question_with_options("q1", "First")]); + request.is_blocking = false; + assert_eq!(request.auto_resolution_ms, None); + let mut overlay = RequestUserInputOverlay::new( + request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + overlay.request_started_at = now; + + assert_eq!( + overlay.auto_resolution_timing_at(now), + AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE + } + ); + + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + let due = now + total_timeout; + assert!(overlay.pre_draw_tick(due)); + assert!(overlay.done); + + let event = rx.try_recv().expect("expected UserInputAnswer event"); + let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else { + panic!("expected UserInputAnswer event"); + }; + assert_eq!(id, "turn-1"); + assert_eq!(response.answers, HashMap::new()); + } + + #[test] + fn auto_resolution_hides_timer_during_grace_period() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + overlay.request_started_at = now; + + assert_eq!( + overlay.auto_resolution_timing_at(now), + AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE + } + ); + assert_eq!( + overlay.auto_resolution_next_frame_delay_at(now), + Some(AUTO_RESOLUTION_HIDDEN_GRACE) + ); + assert!( + !render_snapshot_at(&overlay, Rect::new(0, 0, 120, 16), now).contains("auto-resolves") + ); + } + + #[test] + fn auto_resolution_visible_countdown_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![ + question_with_options("q1", "First"), + question_with_options("q2", "Second"), + question_with_options("q3", "Third"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE; + + insta::assert_snapshot!( + "request_user_input_auto_resolution_countdown", + render_snapshot_at(&overlay, Rect::new(0, 0, 120, 16), now) + ); + } + + #[test] + fn auto_resolution_visible_countdown_is_red() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE; + let area = Rect::new(0, 0, 120, 16); + let mut buf = Buffer::empty(area); + + overlay.render_ui_at(area, &mut buf, now); + + let rendered = snapshot_buffer(&buf); + let progress_line = rendered.lines().nth(1).expect("expected progress line"); + let countdown = "auto-resolves in 1m 00s"; + let countdown_byte_idx = progress_line + .find(countdown) + .expect("expected countdown in progress line"); + let countdown_x = progress_line[..countdown_byte_idx].width(); + for offset in 0..countdown.width() { + assert_eq!( + buf[((countdown_x + offset) as u16, 1)].style().fg, + Some(Color::Red) + ); + } + let prefix_byte_idx = progress_line + .find("Question") + .expect("expected question prefix in progress line"); + let prefix_x = progress_line[..prefix_byte_idx].width(); + assert_ne!(buf[(prefix_x as u16, 1)].style().fg, Some(Color::Red)); + } + + #[test] + fn auto_resolution_expiry_emits_empty_answer() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + overlay.request_started_at = now - total_timeout; + + assert!(overlay.pre_draw_tick(now)); + assert!(overlay.done); + + let event = rx.try_recv().expect("expected UserInputAnswer event"); + let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else { + panic!("expected UserInputAnswer event"); + }; + assert_eq!(id, "turn-1"); + assert_eq!(response.answers, HashMap::new()); + + let event = rx.try_recv().expect("expected history cell event"); + assert!( + matches!(event, AppEvent::InsertHistoryCell(_)), + "expected history cell event" + ); + } + + #[test] + fn auto_resolution_key_interaction_snoozes_timer() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE; + + overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); + + assert_eq!( + overlay.auto_resolution_timing_at(now + total_timeout), + AutoResolutionTiming::Disabled + ); + assert!(!overlay.pre_draw_tick(now + total_timeout)); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn auto_resolution_paste_interaction_snoozes_timer() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let now = Instant::now(); + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + overlay.request_started_at = now - AUTO_RESOLUTION_HIDDEN_GRACE; + + assert!(overlay.handle_paste("notes".to_string())); + + assert_eq!( + overlay.auto_resolution_timing_at(now + total_timeout), + AutoResolutionTiming::Disabled + ); + assert!(!overlay.pre_draw_tick(now + total_timeout)); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn auto_resolution_resets_for_queued_request() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event_with_auto_resolution( + "turn-1", + vec![question_with_options("q1", "First")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.try_consume_user_input_request(request_event_with_auto_resolution( + "turn-2", + vec![question_with_options("q2", "Second")], + )); + let now = Instant::now(); + let total_timeout = AUTO_RESOLUTION_HIDDEN_GRACE + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + overlay.request_started_at = now - total_timeout; + + assert!(overlay.pre_draw_tick(now)); + + assert_eq!(overlay.request.turn_id, "turn-2"); + assert!(!overlay.auto_resolution_snoozed); + assert_eq!( + overlay.auto_resolution_timing_at(now), + AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE + } + ); + assert!(!overlay.done); + assert!(rx.try_recv().is_ok()); + } + + #[test] + fn resolved_request_dismisses_overlay_without_emitting_events() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![question_with_options("q1", "First")], + is_blocking: true, + auto_resolution_ms: None, + }, + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert!( + overlay.dismiss_app_server_request(&ResolvedAppServerRequest::UserInput { + call_id: "call-1".to_string(), + }) + ); + assert!(overlay.done, "resolved request should close the overlay"); + assert!( + rx.try_recv().is_err(), + "dismissing a stale request should not emit an interrupt or answer" + ); + } + + #[test] + fn resolved_current_request_advances_to_next_same_turn_prompt() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![question_with_options("q1", "First")], + is_blocking: true, + auto_resolution_ms: None, + }, + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-2".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![question_with_options("q2", "Second")], + is_blocking: true, + auto_resolution_ms: None, + }); + + assert!( + overlay.dismiss_app_server_request(&ResolvedAppServerRequest::UserInput { + call_id: "call-1".to_string(), + }) + ); + + assert!(!overlay.done, "newer same-turn prompt should stay pending"); + assert_eq!(overlay.request.item_id, "call-2"); + assert_eq!(overlay.request.turn_id, "turn-1"); + assert_eq!(overlay.request.questions[0].id, "q2"); + assert!( + rx.try_recv().is_err(), + "dismissing a stale request should not emit an interrupt or answer" + ); + } + + #[test] + fn resolved_queued_request_removes_only_that_prompt() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-1".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![question_with_options("q1", "First")], + is_blocking: true, + auto_resolution_ms: None, + }, + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-2".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![question_with_options("q2", "Second")], + is_blocking: true, + auto_resolution_ms: None, + }); + overlay.try_consume_user_input_request(ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + item_id: "call-3".to_string(), + turn_id: "turn-1".to_string(), + questions: vec![question_with_options("q3", "Third")], + is_blocking: true, + auto_resolution_ms: None, + }); + + assert!( + overlay.dismiss_app_server_request(&ResolvedAppServerRequest::UserInput { + call_id: "call-2".to_string(), + }) + ); + + assert_eq!(overlay.request.item_id, "call-1"); + assert!( + rx.try_recv().is_err(), + "dismissing a stale queued request should not emit an event" + ); + overlay.submit_answers(); + assert_eq!(overlay.request.item_id, "call-3"); + assert_eq!(overlay.request.questions[0].id, "q3"); + assert!( + rx.try_recv().is_ok(), + "submitting the still-current prompt should emit an answer" + ); + assert!( + rx.try_recv().is_ok(), + "submitting the still-current prompt should emit a history cell" + ); + } + + #[test] + fn options_can_submit_empty_when_unanswered() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { id, response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + assert_eq!(id, "turn-1"); + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, Vec::::new()); + } + + #[test] + fn enter_commits_default_selection_on_last_option_question() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, vec!["Option 1".to_string()]); + } + + #[test] + fn enter_commits_default_selection_on_non_last_option_question() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(overlay.current_index(), 1); + let first_answer = &overlay.answers[0]; + assert!(first_answer.answer_committed); + assert_eq!(first_answer.options_state.selected_idx, Some(0)); + assert!( + rx.try_recv().is_err(), + "unexpected AppEvent before full submission" + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let mut expected = HashMap::new(); + expected.insert( + "q1".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["Option 1".to_string()], + }, + ); + expected.insert( + "q2".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["Option 1".to_string()], + }, + ); + assert_eq!(response.answers, expected); + } + + #[test] + fn number_keys_select_and_submit_options() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Char('2'))); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, vec!["Option 2".to_string()]); + } + + #[test] + fn vim_keys_move_option_selection() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.options_state.selected_idx, Some(0)); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Char('j'))); + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.options_state.selected_idx, Some(1)); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Char('k'))); + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.options_state.selected_idx, Some(0)); + } + + #[test] + fn typing_in_options_does_not_open_notes() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.current_index(), 0); + assert_eq!(overlay.notes_ui_visible(), false); + overlay.handle_key_event(KeyEvent::from(KeyCode::Char('x'))); + assert_eq!(overlay.current_index(), 0); + assert_eq!(overlay.notes_ui_visible(), false); + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.composer.current_text_with_pending(), ""); + } + + #[test] + fn h_l_move_between_questions_in_options() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.current_index(), 0); + overlay.handle_key_event(KeyEvent::from(KeyCode::Char('l'))); + assert_eq!(overlay.current_index(), 1); + overlay.handle_key_event(KeyEvent::from(KeyCode::Char('h'))); + assert_eq!(overlay.current_index(), 0); + } + + #[test] + fn left_right_move_between_questions_in_options() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.current_index(), 0); + overlay.handle_key_event(KeyEvent::from(KeyCode::Right)); + assert_eq!(overlay.current_index(), 1); + overlay.handle_key_event(KeyEvent::from(KeyCode::Left)); + assert_eq!(overlay.current_index(), 0); + } + + #[test] + fn horizontal_list_keys_move_between_questions_in_options() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.current_index(), 0); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); + assert_eq!(overlay.current_index(), 1); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); + assert_eq!(overlay.current_index(), 0); + } + + #[test] + fn options_notes_focus_hides_question_navigation_tip() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec![ + "tab to add notes", + "enter to submit answer", + "←/→ to navigate questions", + "esc to interrupt", + ] + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec!["tab or esc to clear notes", "enter to submit answer",] + ); + } + + #[test] + fn freeform_shows_ctrl_p_and_ctrl_n_question_navigation_tip() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Area"), + question_without_options("q2", "Goal"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.move_question(/*next*/ true); + + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec![ + "enter to submit all", + "ctrl + p / ctrl + n change question", + "esc to interrupt", + ] + ); + } + + #[test] + fn freeform_footer_shows_configured_submit_binding() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec!["ctrl + j to submit answer", "esc to interrupt"] + ); + } + + #[test] + fn freeform_footer_displays_configured_chords_without_internal_dispatch_keys() { + for (specs, expected_tips) in [ + ( + KeybindingsSpec::One(KeybindingSpec("ctrl-x enter".to_string())), + vec!["ctrl + x enter to submit answer", "esc to interrupt"], + ), + ( + KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-enter".to_string()), + KeybindingSpec("ctrl-x enter".to_string()), + ]), + vec!["ctrl + enter to submit answer", "esc to interrupt"], + ), + ( + KeybindingsSpec::Many(vec![ + KeybindingSpec("ctrl-x enter".to_string()), + KeybindingSpec("ctrl-enter".to_string()), + ]), + vec!["ctrl + x enter to submit answer", "esc to interrupt"], + ), + ] { + let (tx, _rx) = test_sender(); + let mut config = TuiKeymap::default(); + config.composer.submit = Some(specs); + let keymap = RuntimeKeymap::from_config(&config).expect("valid submit keymap"); + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let tips = overlay.footer_tips(); + + assert_eq!( + tips.iter().map(|tip| tip.text.as_str()).collect::>(), + expected_tips + ); + } + } + + #[test] + fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() { + let (tx, mut rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec![ + "tab or esc to clear notes", + "enter to submit answer", + "f12 to interrupt", + ] + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::F(12))); + + assert_eq!(overlay.done, true); + expect_interrupt_only(&mut rx); + } + + #[test] + fn tab_opens_notes_when_option_selected() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(1); + + assert_eq!(overlay.notes_ui_visible(), false); + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + assert_eq!(overlay.notes_ui_visible(), true); + assert!(matches!(overlay.focus, Focus::Notes)); + } + + #[test] + fn switching_to_options_resets_notes_focus_when_notes_hidden() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_with_options("q2", "Pick one"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert!(matches!(overlay.focus, Focus::Notes)); + overlay.move_question(/*next*/ true); + + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), false); + } + + #[test] + fn switching_from_freeform_with_text_resets_focus_and_keeps_last_option_empty() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_with_options("q2", "Pick one"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay + .composer + .set_text_content("freeform notes".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.move_question(/*next*/ true); + + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), false); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!(overlay.confirm_unanswered_active()); + assert!( + rx.try_recv().is_err(), + "unexpected AppEvent before confirmation submit" + ); + overlay.handle_key_event(KeyEvent::from(KeyCode::Char('1'))); + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, Vec::::new()); + let answer = response.answers.get("q2").expect("answer missing"); + assert_eq!(answer.answers, vec!["Option 1".to_string()]); + } + + #[test] + fn esc_in_notes_mode_without_options_interrupts() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert_eq!(overlay.done, true); + expect_interrupt_only(&mut rx); + } + + #[test] + fn esc_in_options_mode_interrupts() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert_eq!(overlay.done, true); + expect_interrupt_only(&mut rx); + } + + #[test] + fn esc_in_notes_mode_clears_notes_and_hides_ui() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + answer.answer_committed = true; + + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(overlay.done, false); + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), false); + assert_eq!(overlay.composer.current_text_with_pending(), ""); + assert_eq!(answer.draft.text, ""); + assert_eq!(answer.options_state.selected_idx, Some(0)); + assert_eq!(answer.answer_committed, false); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn esc_in_notes_mode_with_text_clears_notes_and_hides_ui() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + answer.answer_committed = true; + + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + overlay.handle_key_event(KeyEvent::from(KeyCode::Char('a'))); + overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(overlay.done, false); + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), false); + assert_eq!(overlay.composer.current_text_with_pending(), ""); + assert_eq!(answer.draft.text, ""); + assert_eq!(answer.options_state.selected_idx, Some(0)); + assert_eq!(answer.answer_committed, false); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn esc_drops_committed_answers() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "First"), + question_without_options("q2", "Second"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert!( + rx.try_recv().is_err(), + "unexpected AppEvent before interruption" + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + expect_interrupt_only(&mut rx); + } + + #[test] + fn backspace_in_options_clears_selection() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(1); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.options_state.selected_idx, None); + assert_eq!(overlay.notes_ui_visible(), false); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn backspace_on_empty_notes_closes_notes_ui() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + assert!(matches!(overlay.focus, Focus::Notes)); + assert_eq!(overlay.notes_ui_visible(), true); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Backspace)); + + let answer = overlay.current_answer().expect("answer missing"); + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), false); + assert_eq!(answer.options_state.selected_idx, Some(0)); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn tab_in_notes_clears_notes_and_hides_ui() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + overlay + .composer + .set_text_content("Some notes".to_string(), Vec::new(), Vec::new()); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + + let answer = overlay.current_answer().expect("answer missing"); + assert!(matches!(overlay.focus, Focus::Options)); + assert_eq!(overlay.notes_ui_visible(), false); + assert_eq!(overlay.composer.current_text_with_pending(), ""); + assert_eq!(answer.draft.text, ""); + assert_eq!(answer.options_state.selected_idx, Some(0)); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn skipped_option_questions_count_as_unanswered() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + assert_eq!(overlay.unanswered_count(), 1); + } + + #[test] + fn highlighted_option_questions_are_unanswered() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + + assert_eq!(overlay.unanswered_count(), 1); + } + + #[test] + fn freeform_requires_enter_with_text_to_mark_answered() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + assert_eq!(overlay.unanswered_count(), 2); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_eq!(overlay.answers[0].answer_committed, true); + assert_eq!(overlay.unanswered_count(), 1); + } + + #[test] + fn freeform_enter_with_empty_text_is_unanswered() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_eq!(overlay.answers[0].answer_committed, false); + assert_eq!(overlay.unanswered_count(), 2); + } + + #[test] + fn freeform_shift_enter_inserts_newline_without_advancing() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ true, + /*disable_paste_burst*/ false, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + + assert_eq!(overlay.current_index(), 0); + assert_eq!(overlay.composer.current_text_with_pending(), "Draft\n"); + assert_eq!(overlay.answers[0].answer_committed, false); + } + + #[test] + fn freeform_uses_configured_composer_submit_binding() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + + assert_eq!(overlay.current_index(), 1); + assert_eq!(overlay.answers[0].answer_committed, true); + } + + #[test] + fn freeform_submit_binding_wins_over_question_navigation() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('n'))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + + overlay + .composer + .set_text_content("Draft".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + + assert_eq!(overlay.current_index(), 1); + assert_eq!(overlay.answers[0].answer_committed, true); + } + + #[test] + fn freeform_questions_submit_empty_when_empty() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, Vec::::new()); + } + + #[test] + fn freeform_draft_is_not_submitted_without_enter() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay + .composer + .set_text_content("Draft text".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, Vec::::new()); + } + + #[test] + fn freeform_commit_resets_when_draft_changes() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "Notes"), + question_without_options("q2", "More"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay + .composer + .set_text_content("Committed".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_eq!(overlay.answers[0].answer_committed, true); + let _ = rx.try_recv(); + + overlay.move_question(/*next*/ false); + overlay + .composer + .set_text_content("Edited".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + overlay.move_question(/*next*/ true); + assert_eq!(overlay.answers[0].answer_committed, false); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, Vec::::new()); + } + + #[test] + fn notes_are_captured_for_selected_option() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(1); + } + overlay.select_current_option(/*committed*/ false); + overlay + .composer + .set_text_content("Notes for option 2".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + let draft = overlay.capture_composer_draft(); + if let Some(answer) = overlay.current_answer_mut() { + answer.draft = draft; + answer.answer_committed = true; + } + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!( + answer.answers, + vec![ + "Option 2".to_string(), + "user_note: Notes for option 2".to_string(), + ] + ); + } + + #[test] + fn notes_submission_commits_selected_option() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + overlay + .composer + .set_text_content("Notes".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_eq!(overlay.current_index(), 1); + let answer = overlay.answers.first().expect("answer missing"); + assert_eq!(answer.options_state.selected_idx, Some(1)); + assert!(answer.answer_committed); + } + + #[test] + fn tab_and_enter_open_notes_for_other_option() { + for key in [KeyCode::Tab, KeyCode::Enter] { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_options_and_other("q1", "Pick one")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let other_idx = overlay.options_len().saturating_sub(1); + overlay + .current_answer_mut() + .expect("answer missing") + .options_state + .selected_idx = Some(other_idx); + + overlay.handle_key_event(KeyEvent::from(key)); + + assert!(matches!(overlay.focus, Focus::Notes)); + assert!(overlay.notes_ui_visible()); + assert!(rx.try_recv().is_err()); + } + } + + #[test] + fn is_other_adds_none_of_the_above_and_submits_it() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_options_and_other("q1", "Pick one")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + let rows = overlay.option_rows(); + let other_row = rows.last().expect("expected none-of-the-above row"); + assert_eq!(other_row.name, " 4. None of the above"); + assert_eq!( + other_row.description.as_deref(), + Some(OTHER_OPTION_DESCRIPTION) + ); + + let other_idx = overlay.options_len().saturating_sub(1); + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(other_idx); + } + overlay + .composer + .set_text_content("Custom answer".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + let draft = overlay.capture_composer_draft(); + if let Some(answer) = overlay.current_answer_mut() { + answer.draft = draft; + answer.answer_committed = true; + } + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!( + answer.answers, + vec![ + OTHER_OPTION_LABEL.to_string(), + "user_note: Custom answer".to_string(), + ] + ); + } + + #[test] + fn large_paste_is_preserved_when_switching_questions() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_without_options("q1", "First"), + question_without_options("q2", "Second"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + let large = "x".repeat(1_500); + overlay.composer.handle_paste(large.clone()); + overlay.move_question(/*next*/ true); + + let draft = &overlay.answers[0].draft; + assert_eq!(draft.pending_pastes.len(), 1); + assert_eq!(draft.pending_pastes[0].1, large); + assert!(draft.text.contains(&draft.pending_pastes[0].0)); + assert_eq!(draft.text_with_pending(), large); + } + + #[test] + fn pending_paste_placeholder_survives_submission_and_back_navigation() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "First"), + question_with_options("q2", "Second"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + let large = "x".repeat(1_200); + overlay.focus = Focus::Notes; + overlay.ensure_selected_for_notes(); + overlay.composer.handle_paste(large.clone()); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Enter)); + overlay.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)); + + let draft = &overlay.answers[0].draft; + assert_eq!(draft.pending_pastes.len(), 1); + assert!(draft.text.contains(&draft.pending_pastes[0].0)); + assert_eq!(draft.text_with_pending(), large); + } + + #[test] + fn request_user_input_options_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Area")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 120, 16); + insta::assert_snapshot!( + "request_user_input_options", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_options_notes_visible_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Area")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + } + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + + let area = Rect::new(0, 0, 120, 16); + insta::assert_snapshot!( + "request_user_input_options_notes_visible", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_tight_height_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Area")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_tight_height", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn layout_allocates_all_wrapped_options_when_space_allows() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_wrapped_options("q1", "Next Step")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + let width = 48u16; + let question_height = overlay.wrapped_question_lines(width).len() as u16; + let options_height = overlay.options_required_height(width); + let extras = 1u16 // progress + .saturating_add(DESIRED_SPACERS_BETWEEN_SECTIONS) + .saturating_add(overlay.footer_required_height(width)); + let height = question_height + .saturating_add(options_height) + .saturating_add(extras); + let sections = overlay.layout_sections(Rect::new(0, 0, width, height)); + + assert_eq!(sections.options_area.height, options_height); + } + + #[test] + fn desired_height_keeps_spacers_and_preferred_options_visible() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_wrapped_options("q1", "Next Step")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + let width = 110u16; + let height = overlay.desired_height(width); + let content_area = menu_surface_inset(Rect::new(0, 0, width, height)); + let sections = overlay.layout_sections(content_area); + let preferred = overlay.options_preferred_height(content_area.width); + + assert_eq!(sections.options_area.height, preferred); + let question_bottom = sections.question_area.y + sections.question_area.height; + let options_bottom = sections.options_area.y + sections.options_area.height; + let spacer_after_question = sections.options_area.y.saturating_sub(question_bottom); + let spacer_after_options = sections.notes_area.y.saturating_sub(options_bottom); + assert_eq!(spacer_after_question, 1); + assert_eq!(spacer_after_options, 1); + } + + #[test] + fn footer_wraps_tips_without_splitting_individual_tips() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + + let width = 36u16; + let lines = overlay.footer_tip_lines(width); + assert!(lines.len() > 1); + let separator_width = UnicodeWidthStr::width(TIP_SEPARATOR); + for tips in lines { + let used = tips.iter().enumerate().fold(0usize, |acc, (idx, tip)| { + let tip_width = UnicodeWidthStr::width(tip.text.as_str()).min(width as usize); + let extra = if idx == 0 { + tip_width + } else { + separator_width.saturating_add(tip_width) + }; + acc.saturating_add(extra) + }); + assert!(used <= width as usize); + } + } + + #[test] + fn request_user_input_wrapped_options_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_wrapped_options("q1", "Next Step")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + } + + let width = 110u16; + let question_height = overlay.wrapped_question_lines(width).len() as u16; + let options_height = overlay.options_required_height(width); + let height = 1u16 + .saturating_add(question_height) + .saturating_add(options_height) + .saturating_add(8); + let area = Rect::new(0, 0, width, height); + insta::assert_snapshot!( + "request_user_input_wrapped_options", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_long_option_text_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_very_long_option_text("q1", "Status")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 120, 18); + insta::assert_snapshot!( + "request_user_input_long_option_text", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn selected_long_wrapped_option_stays_visible() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_long_scroll_options("q1", "Scroll")], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(2); + + let rendered = render_snapshot(&overlay, Rect::new(0, 0, 80, 20)); + assert!( + rendered.contains("› 3. Use Detailed Hint C"), + "expected selected option to be visible in viewport\n{rendered}" + ); + } + + #[test] + fn request_user_input_footer_wrap_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Pick one"), + question_with_options("q2", "Pick two"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(1); + + let width = 52u16; + let height = overlay.desired_height(width); + let area = Rect::new(0, 0, width, height); + insta::assert_snapshot!( + "request_user_input_footer_wrap", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_scroll_options_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ToolRequestUserInputQuestion { + id: "q1".to_string(), + header: "Next Step".to_string(), + question: "What would you like to do next?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Discuss a code change (Recommended)".to_string(), + description: "Walk through a plan and edit code together.".to_string(), + }, + ToolRequestUserInputOption { + label: "Run tests".to_string(), + description: "Pick a crate and run its tests.".to_string(), + }, + ToolRequestUserInputOption { + label: "Review a diff".to_string(), + description: "Summarize or review current changes.".to_string(), + }, + ToolRequestUserInputOption { + label: "Refactor".to_string(), + description: "Tighten structure and remove dead code.".to_string(), + }, + ToolRequestUserInputOption { + label: "Ship it".to_string(), + description: "Finalize and open a PR.".to_string(), + }, + ]), + }], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(3); + } + let area = Rect::new(0, 0, 120, 12); + insta::assert_snapshot!( + "request_user_input_scrolling_options", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_hidden_options_footer_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ToolRequestUserInputQuestion { + id: "q1".to_string(), + header: "Next Step".to_string(), + question: "What would you like to do next?".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Discuss a code change (Recommended)".to_string(), + description: "Walk through a plan and edit code together.".to_string(), + }, + ToolRequestUserInputOption { + label: "Run tests".to_string(), + description: "Pick a crate and run its tests.".to_string(), + }, + ToolRequestUserInputOption { + label: "Review a diff".to_string(), + description: "Summarize or review current changes.".to_string(), + }, + ToolRequestUserInputOption { + label: "Refactor".to_string(), + description: "Tighten structure and remove dead code.".to_string(), + }, + ToolRequestUserInputOption { + label: "Ship it".to_string(), + description: "Finalize and open a PR.".to_string(), + }, + ]), + }], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(3); + } + let area = Rect::new(0, 0, 80, 10); + insta::assert_snapshot!( + "request_user_input_hidden_options_footer", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_freeform_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_freeform", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_freeform_chord_only_submit_snapshot() { + let (tx, _rx) = test_sender(); + let mut config = TuiKeymap::default(); + config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec( + "ctrl-x enter".to_string(), + ))); + let keymap = RuntimeKeymap::from_config(&config).expect("valid submit chord"); + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 120, /*height*/ 10, + ); + let snapshot = render_snapshot(&overlay, area) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(snapshot, @r" + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + ctrl + x enter to submit answer | esc to interrupt + "); + } + + #[test] + fn request_user_input_freeform_remapped_submit_snapshot() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.composer.submit = vec![crate::key_hint::ctrl(KeyCode::Char('j'))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_freeform_remapped_submit", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_freeform_remapped_interrupt_snapshot() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_freeform_remapped_interrupt", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_multi_question_first_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Area"), + question_without_options("q2", "Goal"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + let area = Rect::new(0, 0, 120, 15); + insta::assert_snapshot!( + "request_user_input_multi_question_first", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_multi_question_last_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Area"), + question_without_options("q2", "Goal"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.move_question(/*next*/ true); + let area = Rect::new(0, 0, 120, 12); + insta::assert_snapshot!( + "request_user_input_multi_question_last", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_unanswered_confirmation_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![ + question_with_options("q1", "Area"), + question_without_options("q2", "Goal"), + ], + ), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + + overlay.open_unanswered_confirmation(); + + let area = Rect::new(0, 0, 80, 12); + insta::assert_snapshot!( + "request_user_input_unanswered_confirmation", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn options_scroll_while_editing_notes() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + ); + overlay.select_current_option(/*committed*/ false); + overlay.focus = Focus::Notes; + overlay + .composer + .set_text_content("Notes".to_string(), Vec::new(), Vec::new()); + overlay.composer.move_cursor_to_end(); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.options_state.selected_idx, Some(1)); + assert!(!answer.answer_committed); + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs new file mode 100644 index 0000000000000000000000000000000000000000..a585d89e957fb9a3dce47be64553bc3da4dbc1ed --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -0,0 +1,577 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use std::borrow::Cow; +use std::time::Instant; +use unicode_segmentation::UnicodeSegmentation; + +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::selection_popup_common::measure_rows_height; +use crate::bottom_pane::selection_popup_common::menu_surface_inset; +use crate::bottom_pane::selection_popup_common::menu_surface_padding_height; +use crate::bottom_pane::selection_popup_common::render_menu_surface; +use crate::bottom_pane::selection_popup_common::render_rows; +use crate::bottom_pane::selection_popup_common::wrap_styled_line; +use crate::line_truncation::line_width; +use crate::render::renderable::Renderable; +use crate::width::display_width; + +use super::DESIRED_SPACERS_BETWEEN_SECTIONS; +use super::RequestUserInputOverlay; +use super::TIP_SEPARATOR; + +const MIN_OVERLAY_HEIGHT: usize = 8; +const PROGRESS_ROW_HEIGHT: usize = 1; +const SPACER_ROWS_WITH_NOTES: usize = 1; +const SPACER_ROWS_NO_OPTIONS: usize = 0; + +struct UnansweredConfirmationData { + title_line: Line<'static>, + subtitle_line: Line<'static>, + hint_line: Line<'static>, + rows: Vec, + state: ScrollState, +} + +struct UnansweredConfirmationLayout { + header_lines: Vec>, + hint_lines: Vec>, + rows: Vec, + state: ScrollState, +} + +fn line_to_owned(line: Line<'_>) -> Line<'static> { + Line { + style: line.style, + alignment: line.alignment, + spans: line + .spans + .into_iter() + .map(|span| Span { + style: span.style, + content: Cow::Owned(span.content.into_owned()), + }) + .collect(), + } +} + +impl Renderable for RequestUserInputOverlay { + fn desired_height(&self, width: u16) -> u16 { + if self.confirm_unanswered_active() { + return self.unanswered_confirmation_height(width); + } + let outer = Rect::new(0, 0, width, u16::MAX); + let inner = menu_surface_inset(outer); + let inner_width = inner.width.max(1); + let has_options = self.has_options(); + let question_height = self.wrapped_question_lines(inner_width).len(); + let options_height = if has_options { + self.options_preferred_height(inner_width) as usize + } else { + 0 + }; + let notes_visible = !has_options || self.notes_ui_visible(); + let notes_height = if notes_visible { + self.notes_input_height(inner_width) as usize + } else { + 0 + }; + // When notes are visible, the composer already separates options from the footer. + // Without notes, we keep extra spacing so the footer hints don't crowd the options. + let spacer_rows = if has_options { + if notes_visible { + SPACER_ROWS_WITH_NOTES + } else { + DESIRED_SPACERS_BETWEEN_SECTIONS as usize + } + } else { + SPACER_ROWS_NO_OPTIONS + }; + let footer_height = self.footer_required_height(inner_width) as usize; + + // Tight minimum height: progress + question + (optional) titles/options + // + notes composer + footer + menu padding. + let mut height = question_height + .saturating_add(options_height) + .saturating_add(spacer_rows) + .saturating_add(notes_height) + .saturating_add(footer_height) + .saturating_add(PROGRESS_ROW_HEIGHT); // progress + height = height.saturating_add(menu_surface_padding_height() as usize); + height.max(MIN_OVERLAY_HEIGHT) as u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_ui_at(area, buf, Instant::now()); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.cursor_pos_impl(area) + } +} + +impl RequestUserInputOverlay { + fn unanswered_confirmation_data(&self) -> UnansweredConfirmationData { + let unanswered = self.unanswered_question_count(); + let subtitle = format!( + "{unanswered} unanswered question{}", + if unanswered == 1 { "" } else { "s" } + ); + UnansweredConfirmationData { + title_line: Line::from(super::UNANSWERED_CONFIRM_TITLE.bold()), + subtitle_line: Line::from(subtitle.dim()), + hint_line: standard_popup_hint_line(), + rows: self.unanswered_confirmation_rows(), + state: self.confirm_unanswered.unwrap_or_default(), + } + } + + fn unanswered_confirmation_layout(&self, width: u16) -> UnansweredConfirmationLayout { + let data = self.unanswered_confirmation_data(); + let content_width = width.max(1); + let mut header_lines = wrap_styled_line(&data.title_line, content_width); + let mut subtitle_lines = wrap_styled_line(&data.subtitle_line, content_width); + header_lines.append(&mut subtitle_lines); + let header_lines = header_lines.into_iter().map(line_to_owned).collect(); + let hint_lines = wrap_styled_line(&data.hint_line, content_width) + .into_iter() + .map(line_to_owned) + .collect(); + UnansweredConfirmationLayout { + header_lines, + hint_lines, + rows: data.rows, + state: data.state, + } + } + + fn unanswered_confirmation_height(&self, width: u16) -> u16 { + let outer = Rect::new(0, 0, width, u16::MAX); + let inner = menu_surface_inset(outer); + let inner_width = inner.width.max(1); + let layout = self.unanswered_confirmation_layout(inner_width); + let rows_height = measure_rows_height( + &layout.rows, + &layout.state, + layout.rows.len().max(1), + inner_width.max(1), + ); + let height = layout.header_lines.len() as u16 + + 1 + + rows_height + + 1 + + layout.hint_lines.len() as u16 + + menu_surface_padding_height(); + height.max(MIN_OVERLAY_HEIGHT as u16) + } + + fn render_unanswered_confirmation(&self, area: Rect, buf: &mut Buffer) { + let content_area = render_menu_surface(area, buf); + if content_area.width == 0 || content_area.height == 0 { + return; + } + let width = content_area.width.max(1); + let layout = self.unanswered_confirmation_layout(width); + + let mut cursor_y = content_area.y; + for line in layout.header_lines { + if cursor_y >= content_area.y + content_area.height { + return; + } + Paragraph::new(line).render( + Rect { + x: content_area.x, + y: cursor_y, + width: content_area.width, + height: 1, + }, + buf, + ); + cursor_y = cursor_y.saturating_add(1); + } + + if cursor_y < content_area.y + content_area.height { + cursor_y = cursor_y.saturating_add(1); + } + + let remaining = content_area + .height + .saturating_sub(cursor_y.saturating_sub(content_area.y)); + if remaining == 0 { + return; + } + + let hint_height = layout.hint_lines.len() as u16; + let spacer_before_hint = u16::from(remaining > hint_height); + let rows_height = remaining.saturating_sub(hint_height + spacer_before_hint); + + let rows_area = Rect { + x: content_area.x, + y: cursor_y, + width: content_area.width, + height: rows_height, + }; + render_rows( + rows_area, + buf, + &layout.rows, + &layout.state, + layout.rows.len().max(1), + "No choices", + ); + + cursor_y = cursor_y.saturating_add(rows_height); + if spacer_before_hint > 0 { + cursor_y = cursor_y.saturating_add(1); + } + for (offset, line) in layout.hint_lines.into_iter().enumerate() { + let y = cursor_y.saturating_add(offset as u16); + if y >= content_area.y + content_area.height { + break; + } + Paragraph::new(line).render( + Rect { + x: content_area.x, + y, + width: content_area.width, + height: 1, + }, + buf, + ); + } + } + + pub(super) fn render_ui_at(&self, area: Rect, buf: &mut Buffer, now: Instant) { + if area.width == 0 || area.height == 0 { + return; + } + if self.confirm_unanswered_active() { + self.render_unanswered_confirmation(area, buf); + return; + } + // Paint the same menu surface used by other bottom-pane overlays and + // then render the overlay content inside its inset area. + let content_area = render_menu_surface(area, buf); + if content_area.width == 0 || content_area.height == 0 { + return; + } + let sections = self.layout_sections(content_area); + let notes_visible = self.notes_ui_visible(); + + // Progress header keeps the user oriented across multiple questions. + let progress_line = if let Some(countdown) = self.auto_resolution_countdown_text_at(now) { + Line::from(vec![ + self.progress_prefix_text().dim(), + " · ".dim(), + countdown.red(), + ]) + } else { + Line::from(self.progress_prefix_text().dim()) + }; + Paragraph::new(progress_line).render(sections.progress_area, buf); + + // Question prompt text. + let question_y = sections.question_area.y; + let answered = + self.is_question_answered(self.current_index(), &self.composer.current_text()); + for (offset, line) in sections.question_lines.iter().enumerate() { + if question_y.saturating_add(offset as u16) + >= sections.question_area.y + sections.question_area.height + { + break; + } + let question_line = if answered { + Line::from(line.clone()) + } else { + Line::from(line.clone()).cyan() + }; + Paragraph::new(question_line).render( + Rect { + x: sections.question_area.x, + y: question_y.saturating_add(offset as u16), + width: sections.question_area.width, + height: 1, + }, + buf, + ); + } + + // Build rows with selection markers for the shared selection renderer. + let option_rows = self.option_rows(); + + if self.has_options() { + let mut options_state = self + .current_answer() + .map(|answer| answer.options_state) + .unwrap_or_default(); + if sections.options_area.height > 0 { + // Ensure the selected option is visible in the scroll window. + options_state + .ensure_visible(option_rows.len(), sections.options_area.height as usize); + render_rows_bottom_aligned( + sections.options_area, + buf, + &option_rows, + &options_state, + option_rows.len().max(1), + "No options", + ); + } + } + + if notes_visible && sections.notes_area.height > 0 { + self.render_notes_input(sections.notes_area, buf); + } + + let footer_y = sections + .notes_area + .y + .saturating_add(sections.notes_area.height); + let footer_area = Rect { + x: content_area.x, + y: footer_y, + width: content_area.width, + height: sections.footer_lines, + }; + if footer_area.height == 0 { + return; + } + let options_hidden = self.has_options() + && sections.options_area.height > 0 + && self.options_required_height(content_area.width) > sections.options_area.height; + let option_tip = if options_hidden { + let selected = self.selected_option_index().unwrap_or(0).saturating_add(1); + let total = self.options_len(); + Some(super::FooterTip::new(format!("option {selected}/{total}"))) + } else { + None + }; + let tip_lines = self.footer_tip_lines_with_prefix(footer_area.width, option_tip); + for (row_idx, tips) in tip_lines + .into_iter() + .take(footer_area.height as usize) + .enumerate() + { + let mut spans = Vec::new(); + for (tip_idx, tip) in tips.into_iter().enumerate() { + if tip_idx > 0 { + spans.push(TIP_SEPARATOR.into()); + } + if tip.highlight { + spans.push(tip.text.cyan().bold().not_dim()); + } else { + spans.push(tip.text.into()); + } + } + let line = Line::from(spans).dim(); + let line = truncate_line_word_boundary_with_ellipsis(line, footer_area.width as usize); + let row_area = Rect { + x: footer_area.x, + y: footer_area.y.saturating_add(row_idx as u16), + width: footer_area.width, + height: 1, + }; + Paragraph::new(line).render(row_area, buf); + } + } + + /// Return the cursor position when editing notes, if visible. + pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> { + if self.confirm_unanswered_active() { + return None; + } + let has_options = self.has_options(); + let notes_visible = self.notes_ui_visible(); + + if !self.focus_is_notes() { + return None; + } + if has_options && !notes_visible { + return None; + } + let content_area = menu_surface_inset(area); + if content_area.width == 0 || content_area.height == 0 { + return None; + } + let sections = self.layout_sections(content_area); + let input_area = sections.notes_area; + if input_area.width == 0 || input_area.height == 0 { + return None; + } + self.composer.cursor_pos(input_area) + } + + /// Render the notes composer. + fn render_notes_input(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let is_secret = self + .current_question() + .is_some_and(|question| question.is_secret); + if is_secret { + self.composer.render_with_mask(area, buf, Some('*')); + } else { + self.composer.render(area, buf); + } + } +} + +/// Render rows into `area`, bottom-aligning the visible rows when fewer than +/// `area.height` lines are produced. +/// +/// This keeps footer spacing stable by anchoring the options block to the +/// bottom of its allocated region. +pub(in crate::bottom_pane) fn render_rows_bottom_aligned( + area: Rect, + buf: &mut Buffer, + rows: &[crate::bottom_pane::selection_popup_common::GenericDisplayRow], + state: &ScrollState, + max_results: usize, + empty_message: &str, +) { + if area.width == 0 || area.height == 0 { + return; + } + + let scratch_area = Rect::new(0, 0, area.width, area.height); + let mut scratch = Buffer::empty(scratch_area); + for y in 0..area.height { + for x in 0..area.width { + scratch[(x, y)] = buf[(area.x + x, area.y + y)].clone(); + } + } + let rendered_height = render_rows( + scratch_area, + &mut scratch, + rows, + state, + max_results, + empty_message, + ); + + let visible_height = rendered_height.min(area.height); + let y_offset = area.height.saturating_sub(visible_height); + for y in 0..visible_height { + for x in 0..area.width { + buf[(area.x + x, area.y + y_offset + y)] = scratch[(x, y)].clone(); + } + } +} + +/// Truncate a styled line to `max_width`, preferring a word boundary, and append an ellipsis. +/// +/// This walks spans character-by-character, tracking the last width-safe position and the last +/// whitespace boundary within the available width (excluding the ellipsis width). If the line +/// overflows, it truncates at the last word boundary when possible (falling back to the last +/// fitting character), trims trailing whitespace, then appends an ellipsis styled to match the +/// last visible span (or the line style if nothing was kept). +pub(in crate::bottom_pane) fn truncate_line_word_boundary_with_ellipsis( + line: Line<'static>, + max_width: usize, +) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + if line_width(&line) <= max_width { + return line; + } + + let ellipsis = "…"; + let ellipsis_width = display_width(ellipsis); + if ellipsis_width >= max_width { + return Line::from(ellipsis); + } + let limit = max_width.saturating_sub(ellipsis_width); + + #[derive(Clone, Copy)] + struct BreakPoint { + span_idx: usize, + byte_end: usize, + } + + // Track display width as we scan, along with the best "cut here" positions. + let mut used = 0usize; + let mut last_fit: Option = None; + let mut last_word_break: Option = None; + let mut overflowed = false; + + 'outer: for (span_idx, span) in line.spans.iter().enumerate() { + let text = span.content.as_ref(); + for (byte_idx, grapheme) in text.grapheme_indices(/*is_extended*/ true) { + let grapheme_width = display_width(grapheme); + if used.saturating_add(grapheme_width) > limit { + overflowed = true; + break 'outer; + } + used = used.saturating_add(grapheme_width); + let bp = BreakPoint { + span_idx, + byte_end: byte_idx + grapheme.len(), + }; + last_fit = Some(bp); + if grapheme.chars().all(char::is_whitespace) { + last_word_break = Some(bp); + } + } + } + + // If we never overflowed, the original line already fits. + if !overflowed { + return line; + } + + // Prefer breaking on whitespace; otherwise fall back to the last fitting character. + let chosen_break = last_word_break.or(last_fit); + let Some(chosen_break) = chosen_break else { + return Line::from(ellipsis); + }; + + let line_style = line.style; + let mut spans_out: Vec> = Vec::new(); + for (idx, span) in line.spans.into_iter().enumerate() { + if idx < chosen_break.span_idx { + spans_out.push(span); + continue; + } + if idx == chosen_break.span_idx { + let text = span.content.into_owned(); + let truncated = text[..chosen_break.byte_end].to_string(); + if !truncated.is_empty() { + spans_out.push(Span::styled(truncated, span.style)); + } + } + break; + } + + while let Some(last) = spans_out.last_mut() { + let trimmed = last + .content + .trim_end_matches(char::is_whitespace) + .to_string(); + if trimmed.is_empty() { + spans_out.pop(); + } else { + last.content = trimmed.into(); + break; + } + } + + let ellipsis_style = spans_out + .last() + .map(|span| span.style) + .unwrap_or(line_style); + spans_out.push(Span::styled(ellipsis, ellipsis_style)); + + Line::from(spans_out).style(line_style) +} + +#[cfg(test)] +#[path = "render_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render_tests.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..2bf87d342b28e9e56fbd966b62f6e8cf4b85d710 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render_tests.rs @@ -0,0 +1,37 @@ +use super::truncate_line_word_boundary_with_ellipsis; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +#[test] +fn halfwidth_sound_marks_are_truncated_at_a_grapheme_boundary() { + let line = Line::from("abガ tail"); + + assert_eq!( + truncate_line_word_boundary_with_ellipsis(line, /*max_width*/ 4), + Line::from(vec![Span::raw("ab"), Span::raw("…")]) + ); +} + +#[test] +fn halfwidth_sound_marks_are_truncated_and_rendered_at_a_grapheme_boundary() { + let lines = [ + Line::from(vec!["ab".bold(), "ガ".cyan(), " tail".dim()]), + Line::from(vec!["xy".bold(), "パ".magenta(), " tail".dim()]), + ] + .map(|line| truncate_line_word_boundary_with_ellipsis(line, /*max_width*/ 5)); + let area = Rect::new(0, 0, 5, 2); + let mut buf = Buffer::empty(area); + + Paragraph::new(lines.to_vec()).render(area, &mut buf); + + insta::assert_snapshot!( + "request_user_input_halfwidth_sound_marks_truncation", + format!("{buf:?}\n{lines:#?}") + ); +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__render__tests__request_user_input_halfwidth_sound_marks_truncation.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__render__tests__request_user_input_halfwidth_sound_marks_truncation.snap new file mode 100644 index 0000000000000000000000000000000000000000..4dc0fc13b7f5857397942191a319c81972860913 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__render__tests__request_user_input_halfwidth_sound_marks_truncation.snap @@ -0,0 +1,33 @@ +--- +source: tui/src/bottom_pane/request_user_input/render_tests.rs +expression: "format!(\"{buf:?}\\n{lines:#?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 5, height: 2 }, + content: [ + "abガ…", // hidden by multi-width symbols: [(3, " ")] + "xyパ…", // hidden by multi-width symbols: [(3, " ")] + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 2, y: 1, fg: Magenta, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Magenta, bg: Reset, underline: Reset, modifier: NONE, + ] +} +[ + Line::from_iter([ + Span::from("ab").bold(), + Span::from("カ\u{ff9e}").cyan(), + Span::from("…").cyan(), + ]), + Line::from_iter([ + Span::from("xy").bold(), + Span::from("ハ\u{ff9f}").magenta(), + Span::from("…").magenta(), + ]), +] diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_auto_resolution_countdown.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_auto_resolution_countdown.snap new file mode 100644 index 0000000000000000000000000000000000000000..509bf406739fecd41dec99f25a626f89e15caa8c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_auto_resolution_countdown.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +assertion_line: 1854 +expression: "render_snapshot_at(&overlay, Rect::new(0, 0, 120, 16), now)" +--- + + Question 1/3 (3 unanswered) · auto-resolves in 1m 00s + Choose an option. + + › 1. Option 1 First choice. + 2. Option 2 Second choice. + 3. Option 3 Third choice. + + tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap new file mode 100644 index 0000000000000000000000000000000000000000..4fa267c654268ca01b7407af948b6db9612e2c9d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/2 (2 unanswered) + Choose an option. + + 1. Option 1 First choice. + › 2. Option 2 Second choice. + 3. Option 3 Third choice. + + tab to add notes | enter to submit answer + ←/→ to navigate questions | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap new file mode 100644 index 0000000000000000000000000000000000000000..3ae7b9d624443dc37b03de8963919e9006aeaa73 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + enter to submit answer | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap new file mode 100644 index 0000000000000000000000000000000000000000..0b26b354e33572540220fceb7dcf784d861959e2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + enter to submit answer | f12 to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap new file mode 100644 index 0000000000000000000000000000000000000000..a2aeaa4d3e5b0ba6dab93f69c32df467d83c4dff --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_submit.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + ctrl + j to submit answer | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_hidden_options_footer.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_hidden_options_footer.snap new file mode 100644 index 0000000000000000000000000000000000000000..d643647f79dc3fc15f0f60f8077aea7de677156e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_hidden_options_footer.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + What would you like to do next? + + 2. Run tests Pick a crate and run its tests. + 3. Review a diff Summarize or review current changes. + › 4. Refactor Tighten structure and remove dead code. + + option 4/5 | tab to add notes | enter to submit answer | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_long_option_text.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_long_option_text.snap new file mode 100644 index 0000000000000000000000000000000000000000..ae5e53b477455aa5ef5300504a745db1fce47553 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_long_option_text.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Choose one option. + + › 1. Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/ Keep async job statuses for + unknown (Recommended when triaging long-running background work and status progress tracking and include + transitions) enough context for debugging + retries, stale workers, and + unexpected expiration paths. + 2. Add a short status model Simpler labels with less detail for + quick rollouts. + + tab to add notes | enter to submit answer | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap new file mode 100644 index 0000000000000000000000000000000000000000..d39ee646e9c2331fef5eadc8a2d7cf4ebe582849 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/2 (2 unanswered) + Choose an option. + + › 1. Option 1 First choice. + 2. Option 2 Second choice. + 3. Option 3 Third choice. + + tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap new file mode 100644 index 0000000000000000000000000000000000000000..ff3fb66ba62081e27580a5929a4cfd541686aa41 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 2/2 (2 unanswered) + Share details. + + › Type your answer (optional) + + + + + + enter to submit all | ctrl + p / ctrl + n change question | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap new file mode 100644 index 0000000000000000000000000000000000000000..c93576246d95b716bdec25adf20711b4545b50cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Choose an option. + + › 1. Option 1 First choice. + 2. Option 2 Second choice. + 3. Option 3 Third choice. + + tab to add notes | enter to submit answer | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap new file mode 100644 index 0000000000000000000000000000000000000000..fef6903e661f4ada1a4e9bc95d8f656d4f96086e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Choose an option. + + › 1. Option 1 First choice. + 2. Option 2 Second choice. + 3. Option 3 Third choice. + + › Add notes + + + + + + tab or esc to clear notes | enter to submit answer diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap new file mode 100644 index 0000000000000000000000000000000000000000..2e8d120e44a7593d9277a36211a453684ee660c6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + What would you like to do next? + + 1. Discuss a code change (Recommended) Walk through a plan and edit code together. + 2. Run tests Pick a crate and run its tests. + 3. Review a diff Summarize or review current changes. + › 4. Refactor Tighten structure and remove dead code. + 5. Ship it Finalize and open a PR. + + tab to add notes | enter to submit answer | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap new file mode 100644 index 0000000000000000000000000000000000000000..c93576246d95b716bdec25adf20711b4545b50cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Choose an option. + + › 1. Option 1 First choice. + 2. Option 2 Second choice. + 3. Option 3 Third choice. + + tab to add notes | enter to submit answer | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_unanswered_confirmation.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_unanswered_confirmation.snap new file mode 100644 index 0000000000000000000000000000000000000000..dd689c7267ea096d21df66ba0876823e0c3285aa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_unanswered_confirmation.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Submit with unanswered questions? + 2 unanswered questions + + › 1. Proceed Submit with 2 unanswered questions. + 2. Go back Return to the first unanswered question. + + + + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap new file mode 100644 index 0000000000000000000000000000000000000000..71d32c5abfd43587d5e24ee4a61e97f4b276b9a4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Choose the next step for this task. + + › 1. Discuss a code change Walk through a plan, then implement it together with careful checks. + 2. Run targeted tests Pick the most relevant crate and validate the current behavior first. + 3. Review the diff Summarize the changes and highlight the most important risks and gaps. + + tab to add notes | enter to submit answer | esc to interrupt diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap new file mode 100644 index 0000000000000000000000000000000000000000..0cf228c129f6c597a3a91283fa64a8787e9daad7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + + Reconnect Google Calendar on ChatGPT. + + URL + https://chatgpt.com/apps/google-calendar/connector_google_calendar + + Sign in to this app in your browser, then return here. + + + › 1. Open sign-in URL + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap new file mode 100644 index 0000000000000000000000000000000000000000..94980ff650edaeb55fa929a2a2561ab02da46ef4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_enable_suggestion_with_reason.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + Plan events and schedules. + + Plan and reference events from your calendar + + Use $ to insert this app into the prompt. + + Enable this app to use it for the current request. + Newly installed apps can take a few minutes to appear in /apps. + + + › 1. Manage on ChatGPT + 2. Enable app + 3. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap new file mode 100644 index 0000000000000000000000000000000000000000..ec95ab96fa95ffedc86d6249dc8f9283a36a6d15 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Action required + Server: payments + + Review the payment details to continue. + + URL + https://payments.example/checkout/123 + + Complete the requested action in your browser, then return here. + + + › 1. Open link + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap new file mode 100644 index 0000000000000000000000000000000000000000..14236e2e7612f328dd7c6bbeab4a87695e7c1663 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Finish in Browser + + Complete the requested action in the browser window that just + opened. + Then return here and select "I finished". + + Link: + https://payments.example/checkout/123 + + › 1. I finished + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap new file mode 100644 index 0000000000000000000000000000000000000000..ac47f874184f8b3153e618e42e0a84ef15350d44 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_install_suggestion_with_reason.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + Plan events and schedules. + + Plan and reference events from your calendar + + Install this app in your browser, then return here. + Newly installed apps can take a few minutes to appear in /apps. + After installed, use $ to insert this app into the prompt. + + + › 1. Install on ChatGPT + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..658d8c84ee904bded9bbe04449d468de42c12160 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_additional_permissions_prompt.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" +--- + + Would you like to run the following command? + + Reason: need filesystem access + + Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt` + + $ cat /tmp/readme.txt + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_cross_thread_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_cross_thread_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..69f3847ad1c83ed48858eaa380f5910d3682f185 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_cross_thread_prompt.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "render_overlay_lines(&view, 80)" +--- + + Would you like to run the following command? + + Thread: Robie [explorer] + + $ echo hi + +› 1. Yes, proceed (y) + 2. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel or o to open thread diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_patch_destination_unavailable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_patch_destination_unavailable.snap new file mode 100644 index 0000000000000000000000000000000000000000..d47bc3d6c7c87957560d57d090cdf0325e237d7c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_patch_destination_unavailable.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" +--- + Would you like to make the following edits? + + Description: Apply proposed file edits + Destination: unavailable + +› 1. Yes, proceed (y) + 2. Yes, and don't ask again for these files (a) + 3. No, and tell Codex what to do differently (esc) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..f650e3c766cad7b93be402e953e9b6d6985aa110 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" +--- + + Would you like to grant these permissions? + + Reason: need workspace access + + Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt` + +› 1. Yes, grant these permissions for this turn (y) + 2. Yes, grant for this turn with strict auto review (r) + 3. Yes, grant these permissions for this session (a) + 4. No, continue without permissions (d) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__network_exec_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__network_exec_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..aad5f89aa4d336afcad9ded79a6d0c6c085d565a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__network_exec_prompt.snap @@ -0,0 +1,37 @@ +--- +source: tui/src/bottom_pane/approval_overlay.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 100, height: 12 }, + content: [ + " ", + " Do you want to approve network access to "example.com"? ", + " ", + " Reason: network request blocked ", + " ", + " ", + "› 1. Yes, just this once (y) ", + " 2. Yes, and allow this host for this conversation (a) ", + " 3. Yes, and allow this host in the future (p) ", + " 4. No, and tell Codex what to do differently (esc) ", + " ", + " Press enter to confirm or esc to cancel ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 57, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 10, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, + x: 33, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 28, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 53, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 54, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 45, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 46, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 48, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__effort_tests__effort_transition_keeps_the_full_footer_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__effort_tests__effort_transition_keeps_the_full_footer_row.snap new file mode 100644 index 0000000000000000000000000000000000000000..e02e49dceda8b7c451f0094bd773fd2a1a653aed --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__effort_tests__effort_transition_keeps_the_full_footer_row.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/chat_composer_effort_tests.rs +expression: footer +--- + gpt-5.4 high · feature-branch diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__adjacent_plugin_completion_inserts_separator.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__adjacent_plugin_completion_inserts_separator.snap new file mode 100644 index 0000000000000000000000000000000000000000..c962377f5b93812f0f0b1833b075c3754487884e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__adjacent_plugin_completion_inserts_separator.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @sample @other " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_enabled.snap new file mode 100644 index 0000000000000000000000000000000000000000..2924342dce91f2ccbb156b21e38d447d49677afc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_enabled.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ← for agents · ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap new file mode 100644 index 0000000000000000000000000000000000000000..5d0239538719a8430a480ca83e60f733aea35b96 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands ! for shell commands " +" ctrl + j for newline tab to submit message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + r search history ctrl + c to exit " +" ⌥ + , reasoning down ⌥ + . reasoning up " +" ctrl + t to view transcript " +" ← for agents (empty prompt) " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_status.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_status.snap new file mode 100644 index 0000000000000000000000000000000000000000..eddb4ae5014d25ae259eaf8bac537ac9c0b2d25d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_status.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" model · project · ← for agents " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap new file mode 100644 index 0000000000000000000000000000000000000000..0b88e19a22f054c58f34c195e79afb57a7be94b9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap new file mode 100644 index 0000000000000000000000000000000000000000..015b3ec3403eecd88b01fbc3a6508c9dfbea98bd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__default_unified_mention_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @sa " +" " +" " +"> Sample Plugin Plugin with skills and an MCP server Plugin" +" other (sample) Example skill used in tests. Skill " +" notes (sample) Example skill used in tests. Skill " +" " +" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap new file mode 100644 index 0000000000000000000000000000000000000000..47c97c74d22d4d78041d7b46a08a2490f545791f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_bare_shell_parameter_with_matching_skill.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_bare_shell_parameter_with_matching_skill.snap new file mode 100644 index 0000000000000000000000000000000000000000..679a00bc18af03181abc45397b2f7c5452ac240b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_bare_shell_parameter_with_matching_skill.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @src $_ " +" " +" " +" " +" " +" " +" " +" loading... " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_shell_positional_parameter.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_shell_positional_parameter.snap new file mode 100644 index 0000000000000000000000000000000000000000..7e77a86f0096eb636b15f2dda65efd2fca0e1c33 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_shell_positional_parameter.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @src $1_suffix " +" " +" " +" " +" " +" " +" " +" loading... " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_unbindable_qualified_skill.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_unbindable_qualified_skill.snap new file mode 100644 index 0000000000000000000000000000000000000000..a2a0a5fee01b304c4112b6f3abd657ec04819d3a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__file_popup_ignores_unbindable_qualified_skill.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @src $1plugin:d " +" " +" " +" " +" " +" " +" " +" loading... " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_full.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_full.snap new file mode 100644 index 0000000000000000000000000000000000000000..4324d806e2da6ec94b986b7d52d41430e37c2f72 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_full.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_with_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_with_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..ecaeb5814938c3278d94ce71b910a93ffb258e48 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_with_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_without_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..118ac252911c90be08d57772e5eedebf45d8b5fa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_cycle_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_only.snap new file mode 100644 index 0000000000000000000000000000000000000000..9c950047855ff59b6a9d8e7c41074bf6abaca506 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_empty_mode_only.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anythin " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_full.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_full.snap new file mode 100644 index 0000000000000000000000000000000000000000..f39aefad64aa3a8ea413d9f48a4a887e0c3fea41 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_full.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ? for shortcuts · Plan mode (shift+tab to cycle) 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_with_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_with_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..347ba31648860cf8e0a6d1c79149e0e540941eb4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_with_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" Plan mode (shift+tab to cycle) 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_without_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..006e2a177398aad1dfdedb418ef79da7b6fac541 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_cycle_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" Plan mode (shift+tab to cycle) " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_only.snap new file mode 100644 index 0000000000000000000000000000000000000000..bea268c57eb6e5680811cc56600807cf6a884ce9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_empty_mode_only.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anythin " +" " +" " +" " +" " +" " +" " +" Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_full.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_full.snap new file mode 100644 index 0000000000000000000000000000000000000000..5f0f35382242db0b3e1dcac9dbd5172cfa2ceeff --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_full.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue message · Plan mode 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_message_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_message_without_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..017e3eb2aa8e9e00ca7fbe3342d7f1d0ea77b8e1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_message_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue message · Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_mode_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_mode_only.snap new file mode 100644 index 0000000000000000000000000000000000000000..35a94ac73a7f72227ed7704d8470fd378278cfa2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_mode_only.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_with_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_with_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..77f38dc4e719b7e79481dea076fcdb259a1e1503 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_with_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue · Plan mode 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_without_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..91f917e987d419f00122653b53b014ce59e2137c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_plan_queue_short_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue · Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_full.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_full.snap new file mode 100644 index 0000000000000000000000000000000000000000..10578033269c7f526cb3d1cb4f5b7ad4c72f7083 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_full.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue message 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_message_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_message_without_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..4f44c0424ea1cea0c5e5a49f2379733ef6a8165b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_message_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_mode_only.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_mode_only.snap new file mode 100644 index 0000000000000000000000000000000000000000..e2d1d2e2822d53810dbdbd9e6eabaecf73c3e39e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_mode_only.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_with_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_with_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..b7128fd415a764ca55283699cee2a04a4bcef9e8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_with_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue message 98% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_without_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_without_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..3df7f743287a23b215855799df3f2f6f9b2eea2a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_collapse_queue_short_without_context.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Test " +" " +" " +" " +" " +" " +" " +" tab to queue message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap new file mode 100644 index 0000000000000000000000000000000000000000..efad837915aa43ffd72905268c02a879c9f63238 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands ! for shell commands " +" ctrl + x enter for newline tab to submit message " +" @ for file paths ctrl + v to paste images " +" ctrl + g ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + r search history ctrl + c to exit " +" ⌥ + , reasoning down ⌥ + . reasoning up " +" ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap new file mode 100644 index 0000000000000000000000000000000000000000..7ecc5bba719666ec0d9581ff01e32446a18800ae --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap new file mode 100644 index 0000000000000000000000000000000000000000..7ecc5bba719666ec0d9581ff01e32446a18800ae --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap new file mode 100644 index 0000000000000000000000000000000000000000..9cad17b8648297a4e970a0f83a8766cc05e01118 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap new file mode 100644 index 0000000000000000000000000000000000000000..2fce42cc26b726d04286e3bf34ced8b916decc0b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap new file mode 100644 index 0000000000000000000000000000000000000000..9cad17b8648297a4e970a0f83a8766cc05e01118 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap new file mode 100644 index 0000000000000000000000000000000000000000..5faacfa64f0b88c1021e2eb8b80b37e7aff4a6d7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› h " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap new file mode 100644 index 0000000000000000000000000000000000000000..90cfba24a9b010f131f5ae53800e8c2746c8903f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› cargo test " +" " +" " +" " +" " +" " +" " +" reverse-i-search: c enter accept · esc cancel " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search_unavailable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search_unavailable.snap new file mode 100644 index 0000000000000000000000000000000000000000..7c082b681caaecac6a800aebab7f2f49a92639ff --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search_unavailable.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› draft " +" " +" " +" " +" " +" " +" " +" reverse-i-search: git " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap new file mode 100644 index 0000000000000000000000000000000000000000..2fce42cc26b726d04286e3bf34ced8b916decc0b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_absorbs_bang.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_absorbs_bang.snap new file mode 100644 index 0000000000000000000000000000000000000000..0b09860b593624b44664a51630a337436359bbf3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_absorbs_bang.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"! git status " +" " +" " +" " +" " +" " +" " +" gpt-5.4 high fast · ~/code/codex-1 · Context 0% used Shell mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_escape_exits_empty_mode.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_escape_exits_empty_mode.snap new file mode 100644 index 0000000000000000000000000000000000000000..dac77a38da37840a2aa684c7a61d898fc0959426 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shell_command_escape_exits_empty_mode.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 4485 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" gpt-5.4 high fast · ~/code/codex-1 · Context 0% used " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap new file mode 100644 index 0000000000000000000000000000000000000000..5239a34bd24fecf1299222832cc6915812a18fd3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands ! for shell commands " +" shift + enter for newline tab to submit message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + r search history ctrl + c to exit " +" ⌥ + , reasoning down ⌥ + . reasoning up " +" ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap new file mode 100644 index 0000000000000000000000000000000000000000..582724eea7d4e32bbd96719380918e9de3f4d7c7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands ! for shell commands " +" shift + enter for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + r search history ctrl + c to exit " +" ⌥ + , reasoning down ⌥ + . reasoning up " +" ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap new file mode 100644 index 0000000000000000000000000000000000000000..49eca416c24d6888e1f0b6280fa84cef4d0951ec --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_multiple.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Image #1][Image #2] " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap new file mode 100644 index 0000000000000000000000000000000000000000..3a5dd7a758f16380fd632297b4f0433fd5f642c6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__image_placeholder_single.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Image #1] " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap new file mode 100644 index 0000000000000000000000000000000000000000..d2f77dbec3f708edaa1e4f95b0b8845415d28f85 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1005 chars] " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__light_terminal_palette_composer.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__light_terminal_palette_composer.snap new file mode 100644 index 0000000000000000000000000000000000000000..413a7347cd3f965ce987682145ca60a597c4d55e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__light_terminal_palette_composer.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: "format!(\"{buffer:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 6 }, + content: [ + " ", + "› Ask Codex to do anything ", + " ", + " ", + " ", + " ? for shortcuts 100% context left ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: BOLD, + x: 1, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE, + x: 2, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: DIM, + x: 26, y: 1, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 17, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 29, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 46, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__mention_popup_type_prefixes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__mention_popup_type_prefixes.snap new file mode 100644 index 0000000000000000000000000000000000000000..8bfb4abf3c21aaffbd59e4aa6d8336bcd4a58e6e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__mention_popup_type_prefixes.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $goog " +" " +" " +" " +" Google Calendar [Skill] Find availability and plan event changes " +" Google Calendar [App] Look up events and availability " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap new file mode 100644 index 0000000000000000000000000000000000000000..0d16cec0b4973c147d9099f4fd8f08162ce769a4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__parent_owned_thread_placeholder.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__parent_owned_thread_placeholder.snap new file mode 100644 index 0000000000000000000000000000000000000000..bf5ec9f16a894b39e35cbc5f8c98668046c57f04 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__parent_owned_thread_placeholder.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Viewing sub-agent — direct input is disabled " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plain_space_is_rendered.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plain_space_is_rendered.snap new file mode 100644 index 0000000000000000000000000000000000000000..a2df963f2b8aadf3c0653a63151b15dd55338137 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plain_space_is_rendered.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› a b " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap new file mode 100644 index 0000000000000000000000000000000000000000..0f6c47dca027aaf055b9f3867d8714586a1c751c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_at_mentions_render_with_plugin_accent.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: "format!(\"text: {text}\\nmagenta: {magenta}\")" +--- +text: › @sample plugin +magenta: ^^^^^^^ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap new file mode 100644 index 0000000000000000000000000000000000000000..e46bc96cfaf5bc5b95f55079dd198141b2389f25 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $sa " +" " +" " +" " +" " +" Sample Plugin [Plugin] Plugin that includes the Figma MCP server and Skills for common workflows " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows.snap new file mode 100644 index 0000000000000000000000000000000000000000..cb00c404ba5a9f6955f4e6c986b5fd8b7be7ae5d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +" [Image #1] " +" [Image #2] " +" " +"› describe these " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows_after_delete_first.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows_after_delete_first.snap new file mode 100644 index 0000000000000000000000000000000000000000..fd3cf1f6cfb1f46863f9a0b4ea2ffff9225c4c8c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows_after_delete_first.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +" [Image #1] " +" " +"› describe these " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows_selected.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows_selected.snap new file mode 100644 index 0000000000000000000000000000000000000000..cb00c404ba5a9f6955f4e6c986b5fd8b7be7ae5d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__remote_image_rows_selected.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +" [Image #1] " +" [Image #2] " +" " +"› describe these " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shift_space_is_rendered.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shift_space_is_rendered.snap new file mode 100644 index 0000000000000000000000000000000000000000..a2df963f2b8aadf3c0653a63151b15dd55338137 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shift_space_is_rendered.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› a b " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shortcut_modified_space_is_not_rendered.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shortcut_modified_space_is_not_rendered.snap new file mode 100644 index 0000000000000000000000000000000000000000..04765b65506fb3defc698d902fd329ae9bb5553c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shortcut_modified_space_is_not_rendered.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› ab " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap new file mode 100644 index 0000000000000000000000000000000000000000..15703f0ade1c96e7f9a2fee4d7484128233cdd7f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__shutdown_in_progress.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Shutting down... " +" " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_accepts_digit_leading_skill.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_accepts_digit_leading_skill.snap new file mode 100644 index 0000000000000000000000000000000000000000..e53ca10d1fb16b183067615e85651d5d28b9bd42 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_accepts_digit_leading_skill.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $1p " +" " +" " +" " +" " +" 1password [Skill] Example skill used in tests. " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_closes_after_trailing_space.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_closes_after_trailing_space.snap new file mode 100644 index 0000000000000000000000000000000000000000..f2cc29cf1c59f105fc229d03a377343bc3a316ec --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_closes_after_trailing_space.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $figma " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_closes_between_spaces_before_plain_text.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_closes_between_spaces_before_plain_text.snap new file mode 100644 index 0000000000000000000000000000000000000000..e27fb9b1f4451913187328a63a2302b1a48ed732 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_closes_between_spaces_before_plain_text.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $old word " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_does_not_fuzzy_match_shell_variable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_does_not_fuzzy_match_shell_variable.snap new file mode 100644 index 0000000000000000000000000000000000000000..51c412455df5d05eeac28b5eb8889392c125d5b7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_does_not_fuzzy_match_shell_variable.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $HOME " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap new file mode 100644 index 0000000000000000000000000000000000000000..63cdf4bccc37284e671fe5c9f952e76a22bcef2b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_falls_back_from_bound_skill_with_path_suffix.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $left $bound/path " +" " +" " +" " +" " +" left [Skill] Example skill used in tests. " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_preserves_normal_target_after_ambiguous_probe.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_preserves_normal_target_after_ambiguous_probe.snap new file mode 100644 index 0000000000000000000000000000000000000000..c641293b3a7f29cdce560d34d1bdee5a4d089be1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_preserves_normal_target_after_ambiguous_probe.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $1x $plugin:d " +" " +" " +" " +" " +" deploy (plugin) [Skill] Example skill used in tests. " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap new file mode 100644 index 0000000000000000000000000000000000000000..d2fe3b11e0303c2f687f11caf3eed9f688ebf359 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_left_of_bound_mention.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $unbound-skill $bound-skill continue " +" " +" " +" " +" " +" unbound-skill [Skill] Example skill used in tests. " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap new file mode 100644 index 0000000000000000000000000000000000000000..a3423c8bba778d0f4a211e06c098e4cbefa5a170 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__skill_popup_targets_unbound_mention_right_of_adjacent_bound_mention.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $bound-skill$unbound-skill " +" " +" " +" " +" " +" unbound-skill [Skill] Example skill used in tests. " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap new file mode 100644 index 0000000000000000000000000000000000000000..9714636b39ae7bc07fee6d2307374b004e601577 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /ar " +" " +" " +" /archive archive this session " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap new file mode 100644 index 0000000000000000000000000000000000000000..5aee8ff4d89b4f9639f31315f9579a0d9ee31fe4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_bt.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /bt " +" " +" " +" /btw start a side conversation in an ephemeral fork " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap new file mode 100644 index 0000000000000000000000000000000000000000..2d5b29038a56022a6bec6d8a06cfa691301ca5bc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /mo " +" " +" " +" /model choose what model and reasoning effort to use " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap new file mode 100644 index 0000000000000000000000000000000000000000..3ac116bb46e02e7fc199fb191cb006a71ff4651f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /pet " +" " +" " +" /pets choose or hide the terminal pet " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap new file mode 100644 index 0000000000000000000000000000000000000000..ff5628a2f339a54d8b68b8114ba9812aa524df7e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /res " +" " +" " +" " +" /resume resume a saved chat " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap new file mode 100644 index 0000000000000000000000000000000000000000..50a89ae1abc9799bf29059d50abcdd28307aee9a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_si.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /si " +" " +" " +" /side start a side conversation in an ephemeral fork " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap new file mode 100644 index 0000000000000000000000000000000000000000..8d3f8216db2c874460427be654bf76ab46e33ff1 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› short " +" " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_popup.snap new file mode 100644 index 0000000000000000000000000000000000000000..de2d3b6ecfb892da387e58235b812b328869a529 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @migr " +" " +" " +" " +"> Review database migration /workspace/project Task " +" migration /another/project Task " +" " +" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_selected_composer.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_selected_composer.snap new file mode 100644 index 0000000000000000000000000000000000000000..86f6ee335956a2ac61e3b9beae038f8d27998881 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__task_mention_selected_composer.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @Review database migration " +" " +" " +" " +" " +" " +" " +" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__ultra_accent_upgrades_prompt_glyph.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__ultra_accent_upgrades_prompt_glyph.snap new file mode 100644 index 0000000000000000000000000000000000000000..1ccc97b273c677540f16235beedc9b4423e46d48 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__ultra_accent_upgrades_prompt_glyph.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"» Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_falls_back_from_bound_plugin_on_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_falls_back_from_bound_plugin_on_right.snap new file mode 100644 index 0000000000000000000000000000000000000000..35bff9abb2f909f37a36b2e4608eb0b874b836f3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__unified_mention_popup_falls_back_from_bound_plugin_on_right.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› @left @bound " +" " +" " +" " +" " +"> left Plugin used to test bound mention fallback. Plugin" +" " +" enter insert · esc close · ←/→ switch search modes [All Results] Filesystem Only Plugins " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_composer_search.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_composer_search.snap new file mode 100644 index 0000000000000000000000000000000000000000..295063fe43e2e216d6020a81262b5732eb25352b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_composer_search.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› a ǵg b हि gg " Hidden by multi-width symbols: [(10, " ")] +" " +" " +" " +" " +" " +" " +" ?ह " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_redo_stays_normal.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_redo_stays_normal.snap new file mode 100644 index 0000000000000000000000000000000000000000..cbbd27f11f259753ebcf8b630e2a10bb50bde4c5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_empty_redo_stays_normal.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 5360 +expression: terminal.backend() +--- +" " +"› bc " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Normal " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_fresh_draft.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_fresh_draft.snap new file mode 100644 index 0000000000000000000000000000000000000000..7a31d0b1820fc8423c9246c9b31c06afbbb48f8e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_fresh_draft.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Insert " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_completion_suffix.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_completion_suffix.snap new file mode 100644 index 0000000000000000000000000000000000000000..f5b564d2eebec0c72314f13df485ccbfff54d62e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_completion_suffix.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› ma next " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Replace " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_mode.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_mode.snap new file mode 100644 index 0000000000000000000000000000000000000000..67e6c51e596eeacdf9de74ba72147f463babfb60 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_mode.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› [Pasted Content 1001 chars] " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Replace " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap new file mode 100644 index 0000000000000000000000000000000000000000..3620a75299b36f1a899626e32eb2215320d4cf7d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_replace_slash_recovery.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /dide tail " +" " +" " +" " +" " +" " +" " +" /diff show git diff (including untracked files) " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_search_cancelled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_search_cancelled.snap new file mode 100644 index 0000000000000000000000000000000000000000..423436edfdeb9ab843abfb73b4dccf36abcf2c02 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__vim_search_cancelled.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› draft " +" " +" " +" " +" " +" " +" " +" 100% context left | Vim: Normal " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap new file mode 100644 index 0000000000000000000000000000000000000000..51d3ed54a7f4f99198282a862d755664febe9bba --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_app.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/command_popup.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 72, height: 2 }, + content: [ + " /app continue this session in the Desktop app ", + " /approve approve one retry of a recent auto-review denial ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 52, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 6, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 12, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 60, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap new file mode 100644 index 0000000000000000000000000000000000000000..8e631793d1c140155983c4af38a2fb77375e5c7e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap @@ -0,0 +1,51 @@ +--- +source: tui/src/bottom_pane/command_popup.rs +expression: commands +--- +/model - choose what model and reasoning effort to use +/ide - include current selection, open files, and other context from your IDE +/permissions - choose what Codex is allowed to do +/keymap - remap TUI shortcuts +/vim - toggle Vim mode for the composer +/experimental - toggle experimental features +/approve - approve one retry of a recent auto-review denial +/memories - configure memory use and generation +/skills - use skills to improve how Codex performs specific tasks +/import - import setup, this project, and recent chats from Claude Code +/hooks - view and manage lifecycle hooks +/review - review my current changes and find issues +/rename - rename the current thread +/new - start a new chat during a conversation +/archive - archive this session +/delete - permanently delete this session +/resume - resume a saved chat +/fork - fork the current chat +/app - continue this session in the Desktop app +/init - create an AGENTS.md file with instructions for Codex +/compact - summarize conversation to prevent hitting the context limit +/recap - summarize the current conversation now +/agents - view and switch between all active agent sessions +/side - start a side conversation in an ephemeral fork +/copy - copy the last response or part of it +/export - export the conversation as markdown +/raw - toggle raw scrollback mode for copy-friendly terminal selection +/diff - show git diff (including untracked files) +/mention - mention a file +/status - show current session configuration and token usage +/daemon - Manage the local background server. +/cd - change the current working directory +/pwd - show the current working directory +/title - configure which items appear in the terminal title +/statusline - configure which items appear in the status line +/theme - choose a syntax highlighting theme +/pets - choose or hide the terminal pet +/mcp - list configured MCP tools; use /mcp verbose for details +/logout - log out of Codex +/exit - exit Codex +/feedback - send logs to maintainers +/rollout - print the rollout file path +/ps - list background terminals +/stop - stop all background terminals +/clear - clear the terminal and start a new chat +/test-approval - test approval request +/subagents - switch between this session's subagents diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_filter_reset_after_scroll.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_filter_reset_after_scroll.snap new file mode 100644 index 0000000000000000000000000000000000000000..424c13151cf40675a67a15ed37fd1bf89be65791 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_filter_reset_after_scroll.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/command_popup.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 72, height: 3 }, + content: [ + " /status show current session configuration and token usage ", + " /statusline configure which items appear in the status line ", + " /stop stop all background terminals ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 65, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 15, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 62, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 5, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 15, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 44, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_voice.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_voice.snap new file mode 100644 index 0000000000000000000000000000000000000000..ba3bc2ab738da87339ab2f6160baca71f30391e4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_voice.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/command_popup.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 72, height: 1 }, + content: [ + " /voice start or stop voice; use /voice settings to choose a voice ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 68, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_ignition__tests__effort_ignition_animation_gallery.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_ignition__tests__effort_ignition_animation_gallery.snap new file mode 100644 index 0000000000000000000000000000000000000000..6d5b27a3752a5ea8a20112f15fd0cceef180ef7a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_ignition__tests__effort_ignition_animation_gallery.snap @@ -0,0 +1,46 @@ +--- +source: tui/src/bottom_pane/effort_ignition_tests.rs +expression: "frames.join(\"\\n\")" +--- +wave MAX 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave MAX 180ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave MAX 420ms │ │ > keep my draft exactly as typed │ │...################.........................│...################.........................│...################.........................│ +wave MAX 720ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave MAX 1100ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave MAX 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave MAX 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave ULTRA 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave ULTRA 180ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave ULTRA 420ms │ │ > keep my draft exactly as typed │ │.......################.....................│.......################.....................│.......################.....................│ +wave ULTRA 720ms │ │ > keep my draft exactly as typed │ │.....................................#######│.....................................#######│.....................................#######│ +wave ULTRA 1100ms │ ✧ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave ULTRA 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +wave ULTRA 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +aurora MAX 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +aurora MAX 180ms │ │ > keep my draft exactly as typed │ │.............#################.#############│.............#################.#############│.............#################.#############│ +aurora MAX 420ms │ │ > keep my draft exactly as typed │ │.........................###################│.........................###################│.........................###################│ +aurora MAX 720ms │ │ > keep my draft exactly as typed │ │........................####################│........................####################│........................####################│ +aurora MAX 1100ms │ │ > keep my draft exactly as typed │ │...........########################.........│...........########################.........│...........########################.........│ +aurora MAX 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +aurora MAX 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +aurora ULTRA 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +aurora ULTRA 180ms │ │ > keep my draft exactly as typed │ │.............###############################│.............###############################│.............###############################│ +aurora ULTRA 420ms │ │ > keep my draft exactly as typed │ │################.........###################│################.........###################│################.........###################│ +aurora ULTRA 720ms │ │ > keep my draft exactly as typed │ │....################....####################│....################....####################│....################....####################│ +aurora ULTRA 1100ms │ │ > keep my draft exactly as typed │ │...........#################################│...........#################################│...........#################################│ +aurora ULTRA 1580ms │ │ > keep my draft exactly as typed │ │..##########................................│..##########................................│..##########................................│ +aurora ULTRA 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse MAX 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse MAX 180ms │ │ > keep my draft exactly as typed │ │........########.............########.......│........########.............########.......│........########.............########.......│ +pulse MAX 420ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse MAX 720ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse MAX 1100ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse MAX 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse MAX 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse ULTRA 0ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse ULTRA 180ms │ │ > keep my draft exactly as typed │ │.......########...............########......│.......########...............########......│.......########...............########......│ +pulse ULTRA 420ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse ULTRA 720ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse ULTRA 1100ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse ULTRA 1580ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ +pulse ULTRA 2050ms │ │ > keep my draft exactly as typed │ │............................................│............................................│............................................│ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_status_line__tests__effort_status_line_transition_frames.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_status_line__tests__effort_status_line_transition_frames.snap new file mode 100644 index 0000000000000000000000000000000000000000..8c02023892cc8188f8d8659ba48f87249ec38b9a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__effort_status_line__tests__effort_status_line_transition_frames.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/bottom_pane/effort_status_line_tests.rs +expression: "frames.join(\"\\n\")" +--- +MAX 0ms │gpt-5.4 high · main │ +MAX 300ms │ gpt-5.4 high · main │ +MAX 520ms │ gpt-5.4 high │ +MAX 680ms │ M A X │ +MAX 820ms │ M A X │ +MAX 1050ms │ M A X │ +MAX 1320ms │ M A X │ +MAX 1550ms │ M A X │ +MAX 1800ms │ M A X │ +MAX 2020ms │gpt-5.4 max · main │ +MAX 2250ms │gpt-5.4 max · main │ +MAX 2500ms │gpt-5.4 max · main │ +ULTRA 0ms │gpt-5.4 high · main │ +ULTRA 300ms │ gpt-5.4 high · main │ +ULTRA 520ms │ gpt-5.4 high │ +ULTRA 680ms │ U L T R A │ +ULTRA 820ms │ U L T R A │ +ULTRA 1050ms │ U L T R A │ +ULTRA 1320ms │ U L T R A │ +ULTRA 1550ms │ U L T R A │ +ULTRA 1800ms │ U L T R A │ +ULTRA 2020ms │gpt-5.4 ultra · main │ +ULTRA 2250ms │gpt-5.4 ultra · main │ +ULTRA 2500ms │gpt-5.4 ultra · main │ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_analytics_plan_history.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_analytics_plan_history.snap new file mode 100644 index 0000000000000000000000000000000000000000..048354f30c2a755f02f0a6a9487ada63cef3dc24 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_analytics_plan_history.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + +› [ ] Analytics plan history Preview five-hour and weekly allowance history + for consumer accounts in /analytics. + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_changed_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_changed_default.snap new file mode 100644 index 0000000000000000000000000000000000000000..905d975203f4b365ac739f479a37da1d13399496 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_changed_default.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + +› [x] Server network_proxy Description supplied by the server. + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_empty.snap new file mode 100644 index 0000000000000000000000000000000000000000..f3d09a6abde7b4bd25e7332dffb615d629e98683 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_empty.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + No server experiments available. + + No experimental features available for now + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_save_failed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_save_failed.snap new file mode 100644 index 0000000000000000000000000000000000000000..f529b54d8089c103f9f6c15acb7d39e7d3e9ed60 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_save_failed.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + Saved but readback failed + +› [ ] Server network_proxy Description supplied by the server. + [ ] Server future_feature Description supplied by the server. + [ ] Server prevent_idle_sleep Description supplied by the server. + + Selections retained. Save to retry, or cancel to close. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_saving.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_saving.snap new file mode 100644 index 0000000000000000000000000000000000000000..3b24a44fa529bbe7e12509e24243fa76c8341700 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_saving.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + Saving experimental features… + +› [ ] Server network_proxy Description supplied by the server. + [ ] Server future_feature Description supplied by the server. + [ ] Server prevent_idle_sleep Description supplied by the server. + + Saving… Closing this popup will not cancel the write. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_server_discovery.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_server_discovery.snap new file mode 100644 index 0000000000000000000000000000000000000000..0b35d3e6deb2038a8bf3681ac2f8d1643a611368 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_server_discovery.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect only in new tasks or + after restarting the Codex server. + +› [x] Server network_proxy Description supplied by the server. + [x] Server future_feature Description supplied by the server. + [x] Server prevent_idle_sleep Description supplied by the server. + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_unavailable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_unavailable.snap new file mode 100644 index 0000000000000000000000000000000000000000..fb446fea9b9cbc00033cdda6523e6979ff3081ee --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__experimental_features_unavailable.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + Server experiments unavailable. Reopen /experimental to retry; restart this + Codex client if requests remain unanswered. + + No experimental features available for now + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_available.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_available.snap new file mode 100644 index 0000000000000000000000000000000000000000..b1159ac9ffb7b95b985121f479c1f63e5fe888be --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_available.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +assertion_line: 208 +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + +› [x] Server realtime_conversation Description supplied by the server. + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_unavailable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_unavailable.snap new file mode 100644 index 0000000000000000000000000000000000000000..12654f5dc06e8bd0452dbbcc389d502288b987cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__voice_runtime_unavailable.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +assertion_line: 208 +expression: buffer_text(&buffer) +--- + + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + No server experiments available. + + No experimental features available for now + + Press space to select or enter to save diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_four_rows.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_four_rows.snap new file mode 100644 index 0000000000000000000000000000000000000000..78d4b6dc194d5d1cb758404cac06f2d5efafb018 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_four_rows.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: "format!(\"{initial}\\n\\n{scrolled}\")" +--- +▌ Your data may be used to improve our models and products +▌ +▌ A visible note. +Press enter to submit or esc to cancel + +▌ sensitive personal information. +▌ +▌ A visible note. +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_short_panes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_short_panes.snap new file mode 100644 index 0000000000000000000000000000000000000000..c3a55552085dd02fa7cd72f26d04e8ea3c93fc3a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosure_short_panes.snap @@ -0,0 +1,75 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: "rendered.join(\"\\n\\n\")" +--- +40x12 initial +▌ Tell us more (bug) +▌ +▌ Your data may be used to improve our +▌ models and products +▌ By submitting feedback, you agree that +▌ OpenAI can use your feedback for +▌ safety purposes and internal model +▌ training, as explained in more detail +▌ +▌ A visible note. +pgup/pgdn scroll disclosure +Press enter to submit or esc to cancel + +40x12 scrolled +▌ By submitting feedback, you agree that +▌ OpenAI can use your feedback for +▌ safety purposes and internal model +▌ training, as explained in more detail +▌ here. Please avoid sharing tented +▌ information under NDA, or privileged, +▌ HR-related, or sensitive personal +▌ information. +▌ +▌ A visible note. +pgup/pgdn scroll disclosure +Press enter to submit or esc to cancel + +40x12 restored +▌ Tell us more (bug) +▌ +▌ Your data may be used to improve our +▌ models and products +▌ By submitting feedback, you agree that +▌ OpenAI can use your feedback for +▌ safety purposes and internal model +▌ training, as explained in more detail +▌ +▌ A visible note. +pgup/pgdn scroll disclosure +Press enter to submit or esc to cancel + +80x8 initial +▌ Tell us more (bug) +▌ +▌ Your data may be used to improve our models and products +▌ By submitting feedback, you agree that OpenAI can use your feedback for safety +▌ +▌ A visible note. +pgup/pgdn scroll disclosure +Press enter to submit or esc to cancel + +80x8 scrolled +▌ By submitting feedback, you agree that OpenAI can use your feedback for safety +▌ purposes and internal model training, as explained in more detail here. Please +▌ avoid sharing tented information under NDA, or privileged, HR-related, or +▌ sensitive personal information. +▌ +▌ A visible note. +pgup/pgdn scroll disclosure +Press enter to submit or esc to cancel + +80x8 restored +▌ Tell us more (bug) +▌ +▌ Your data may be used to improve our models and products +▌ By submitting feedback, you agree that OpenAI can use your feedback for safety +▌ +▌ A visible note. +pgup/pgdn scroll disclosure +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosures.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosures.snap new file mode 100644 index 0000000000000000000000000000000000000000..8440474e66a234938f4b594a2967b4ce262d3d0d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_disclosures.snap @@ -0,0 +1,53 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: "rendered.join(\"\\n\\n\")" +--- +external (80 columns) +▌ Tell us more (bug) +▌ +▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ +▌ (optional) Write a short description to help us further + +Press enter to submit or esc to cancel + +external (40 columns) +▌ Tell us more (bug) +▌ +▌ Your feedback can be used to improve +▌ ChatGPT. Learn more. +▌ +▌ (optional) Write a short description t + +Press enter to submit or esc to cancel + +employee (80 columns) +▌ Tell us more (bug) +▌ +▌ Your data may be used to improve our models and products +▌ By submitting feedback, you agree that OpenAI can use your feedback for safety +▌ purposes and internal model training, as explained in more detail here. Please +▌ avoid sharing tented information under NDA, or privileged, HR-related, or +▌ sensitive personal information. +▌ +▌ (optional) Write a short description to help us further + +Press enter to submit or esc to cancel + +employee (40 columns) +▌ Tell us more (bug) +▌ +▌ Your data may be used to improve our +▌ models and products +▌ By submitting feedback, you agree that +▌ OpenAI can use your feedback for +▌ safety purposes and internal model +▌ training, as explained in more detail +▌ here. Please avoid sharing tented +▌ information under NDA, or privileged, +▌ HR-related, or sensitive personal +▌ information. +▌ +▌ (optional) Write a short description t + +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_multiline_paste.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_multiline_paste.snap new file mode 100644 index 0000000000000000000000000000000000000000..4b2ff023a1c930e5dd9c65a0e979c595637fa201 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_multiline_paste.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: "render(&view, 60)" +--- +▌ Tell us more (bug) +▌ +▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ +▌ foo +▌ bar + +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bad_result.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bad_result.snap new file mode 100644 index 0000000000000000000000000000000000000000..41867fcf6bce474970c5644bff2ac92bc935c284 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bad_result.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: rendered +--- +▌ Tell us more (bad result) +▌ +▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ +▌ (optional) Write a short description to help us further + +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bug.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bug.snap new file mode 100644 index 0000000000000000000000000000000000000000..2183ee610567fce00f0fba2331c19ff1d67db1fd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_bug.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: rendered +--- +▌ Tell us more (bug) +▌ +▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ +▌ (optional) Write a short description to help us further + +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_good_result.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_good_result.snap new file mode 100644 index 0000000000000000000000000000000000000000..8c1f63b22668075a40e9a004e76f752780aa8019 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_good_result.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: rendered +--- +▌ Tell us more (good result) +▌ +▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ +▌ (optional) Write a short description to help us further + +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_other.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_other.snap new file mode 100644 index 0000000000000000000000000000000000000000..14af47e54cd7629ac0f01c64913c39acd6960829 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_other.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: rendered +--- +▌ Tell us more (other) +▌ +▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ +▌ (optional) Write a short description to help us further + +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_safety_check.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_safety_check.snap new file mode 100644 index 0000000000000000000000000000000000000000..faee63e0e421e6891f3d499c8296a05bbeb72ea6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_safety_check.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: rendered +--- +▌ Tell us more (safety check) +▌ +▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ +▌ (optional) Share what was refused and why it should have b + +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_with_connectivity_diagnostics.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_with_connectivity_diagnostics.snap new file mode 100644 index 0000000000000000000000000000000000000000..2183ee610567fce00f0fba2331c19ff1d67db1fd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_note_view__tests__feedback_view_with_connectivity_diagnostics.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/feedback_note_view_tests.rs +expression: rendered +--- +▌ Tell us more (bug) +▌ +▌ Your feedback can be used to improve ChatGPT. Learn more. +▌ +▌ (optional) Write a short description to help us further + +Press enter to submit or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_success_employee.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_success_employee.snap new file mode 100644 index 0000000000000000000000000000000000000000..b69ba4bf3972d9835687658ec37504f9c9c84bee --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_success_employee.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- +• Feedback uploaded. You can share this in #codex-feedback: + + http://go/codex-feedback-internal + + Sentry Feedback ID: thread-2 + Sentry URL: https://go/codex-feedback/thread-2 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap new file mode 100644 index 0000000000000000000000000000000000000000..c5abceac690580572a7588139d7570e452a38a09 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +assertion_line: 728 +expression: rendered +--- +Upload logs? + +The following files will be sent: + • codex-logs.log + • codex-doctor-report.json + • codex-apps-tools-cache.json (if available) + • codex-app-directory-cache.json (if available) + • rollout.jsonl + • auto-review-rollout.jsonl diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap new file mode 100644 index 0000000000000000000000000000000000000000..68474648aa27b41d576ec55000178c3e53381149 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +assertion_line: 746 +expression: rendered +--- +Upload logs? + +The following files will be sent: + • codex-logs.log + • codex-doctor-report.json + • codex-apps-tools-cache.json (if available) + • codex-app-directory-cache.json (if available) + • windows-sandbox.log + • rollout.jsonl + • auto-review-rollout.jsonl diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap new file mode 100644 index 0000000000000000000000000000000000000000..98fabc8444fd0939d2cda71ac18f4db72f55694f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- + Do you want to upload logs before reporting issue? + + Logs may include the full conversation history of this Codex process + These logs are retained for 90 days and are used solely for troubles + + You can review the exact content of the logs before they’re uploaded + + + +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. + 2. No + 3. Cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_active_agent_label.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_active_agent_label.snap new file mode 100644 index 0000000000000000000000000000000000000000..60a64064bf332d758326b81fcbc712702d757edf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_active_agent_label.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Robie [explorer] 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_composer_has_draft_queue_hint_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_composer_has_draft_queue_hint_enabled.snap new file mode 100644 index 0000000000000000000000000000000000000000..71370d83ba836e4dd1ecb67f64c4cdeced3a209c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_composer_has_draft_queue_hint_enabled.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" tab to queue message 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap new file mode 100644 index 0000000000000000000000000000000000000000..b7ee60704ce6defe6ef9ed582624c3ef47ecbe5a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ? for shortcuts 123K used " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap new file mode 100644 index 0000000000000000000000000000000000000000..31a1b743b8e38e3571079e3bf4ee0cb7c500f831 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap new file mode 100644 index 0000000000000000000000000000000000000000..31a1b743b8e38e3571079e3bf4ee0cb7c500f831 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap new file mode 100644 index 0000000000000000000000000000000000000000..b2333b025f6f4d4deeca240340dec19e6ec4e4a9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap new file mode 100644 index 0000000000000000000000000000000000000000..20f9b178b4b8ca44ddb9dfd66e7e27cd20f08bd7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_narrow_overlap_hides.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_narrow_overlap_hides.snap new file mode 100644 index 0000000000000000000000000000000000000000..6266f43d0bb2024ba8155630cc30a14fda1228ed --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_narrow_overlap_hides.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Plan mode (shift+tab to cycle) " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_running_hides_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_running_hides_hint.snap new file mode 100644 index 0000000000000000000000000000000000000000..9f9be080da156dd890f27da5cc57cdb99aa96713 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_running_hides_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ? for shortcuts · Plan mode 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_wide.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_wide.snap new file mode 100644 index 0000000000000000000000000000000000000000..8c32ee50dc8292fb86ee01e9a4dd3083da5ef89c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_mode_indicator_wide.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ? for shortcuts · Plan mode (shift+tab to cycle) 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap new file mode 100644 index 0000000000000000000000000000000000000000..3b67a90683e07a76aa431b23706c2b787433ec2c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands ! for shell commands " +" ctrl + j for newline tab to submit message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + r search history ctrl + c to exit " +" ⌥ + , reasoning down ⌥ + . reasoning up " +" shift + tab to change mode ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap new file mode 100644 index 0000000000000000000000000000000000000000..2a81b8557604ed38fdecb10a331ec657956507f2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ? for shortcuts 72% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap new file mode 100644 index 0000000000000000000000000000000000000000..02804e5735ed3633db562072e292095830cf9b82 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap new file mode 100644 index 0000000000000000000000000000000000000000..7c733bd13b0e7d7b6b001b9f2d8122a569819b42 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands ! for shell commands " +" ctrl + j for newline tab to queue message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc esc to edit previous message " +" ctrl + r search history ctrl + c to interrupt " +" ⌥ + , reasoning down ⌥ + . reasoning up " +" ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap new file mode 100644 index 0000000000000000000000000000000000000000..07e970628356e64b005a0445faa4baf3d07a0e25 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands ! for shell commands " +" shift + enter for newline tab to submit message " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + r search history ctrl + c to exit " +" ⌥ + , reasoning down ⌥ + . reasoning up " +" ctrl + t to view transcript " +" " +" customize shortcuts with /keymap " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_disabled_context_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_disabled_context_right.snap new file mode 100644 index 0000000000000000000000000000000000000000..b86792ac777cc92db55e86553a0bdb65f85492df --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_disabled_context_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ? for shortcuts · Plan mode (shift+tab to cycle) 50% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap new file mode 100644 index 0000000000000000000000000000000000000000..1e340ddc823e391224dd1a08eba285fad9224dcc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_and_ide_context_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Plan mode (shift+tab to cycle) · IDE context " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_right.snap new file mode 100644 index 0000000000000000000000000000000000000000..2da49eeb64095a2ebd7b77fa993e32702152f375 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_mode_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Plan mode (shift+tab to cycle) " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_no_mode_right.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_no_mode_right.snap new file mode 100644 index 0000000000000000000000000000000000000000..68138916136cb80a4bf34b330fafdc0c8be6ee7d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_enabled_no_mode_right.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_context.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_context.snap new file mode 100644 index 0000000000000000000000000000000000000000..d3958253e3133c9fbcaf881f9d95d607be340a77 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_context.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Italic text " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_draft_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_draft_idle.snap new file mode 100644 index 0000000000000000000000000000000000000000..bb0e2d33b946c8644ea2dfe4a27591e238ddb66c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_draft_idle.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Status line content " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_shortcuts.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_shortcuts.snap new file mode 100644 index 0000000000000000000000000000000000000000..bb0e2d33b946c8644ea2dfe4a27591e238ddb66c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_overrides_shortcuts.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Status line content " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_truncated_with_gap.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_truncated_with_gap.snap new file mode 100644 index 0000000000000000000000000000000000000000..cef1531fd6e4164fa48305fd8c085354089dc515 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_truncated_with_gap.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Status line content that … Plan mode " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_with_active_agent_label.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_with_active_agent_label.snap new file mode 100644 index 0000000000000000000000000000000000000000..c09d61510a758015226b425b2313ba30043e255c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_with_active_agent_label.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" Status line content · Robie [explorer] " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_yields_to_queue_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_yields_to_queue_hint.snap new file mode 100644 index 0000000000000000000000000000000000000000..71370d83ba836e4dd1ecb67f64c4cdeced3a209c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_status_line_yields_to_queue_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" tab to queue message 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__voice_footer_rendered_styles.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__voice_footer_rendered_styles.snap new file mode 100644 index 0000000000000000000000000000000000000000..d71648f1d65b68f62a0f7f55da30f1456d171738 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__voice_footer_rendered_styles.snap @@ -0,0 +1,34 @@ +--- +source: tui/src/bottom_pane/footer.rs +assertion_line: 1355 +expression: "(backend.to_string(), style_runs)" +--- +( + "\" voice ● listen ctrl+m mute \"\n", + [ + ( + 0, + Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset), + ), + ( + 3, + Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold(), + ), + ( + 8, + Style::new().red().bg(Color::Reset).underline_color(Color::Reset), + ), + ( + 10, + Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset), + ), + ( + 20, + Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold(), + ), + ( + 26, + Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset), + ), + ], +) diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_additional_context_limit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_additional_context_limit.snap new file mode 100644 index 0000000000000000000000000000000000000000..5df46276dc008e86b034a3f84d3dac281c0aba09 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_additional_context_limit.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + + Event PreToolUse + Matcher Bash + Source User config - /tmp/hooks.json + Command /tmp/pre-tool-use.sh + Mode Sync + Timeout 30s + Context unlimited + Trust Trusted + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap new file mode 100644 index 0000000000000000000000000000000000000000..6597de15bebf9e795a4b5d75479649acea42face --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 44)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are s + + [x] Hook 1 + + Event PreToolUse + Matcher Bash + Source User config - /tmp/h.json + Command one two three four five six + seven eight nine ten eleven + twelve thirteen fourteen… + Mode Sync + Timeout 30s + Trust Trusted + + Press space or enter to toggle; esc to go diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap new file mode 100644 index 0000000000000000000000000000000000000000..33321eec2be382fafdfb8a788c7697fde2e8f573 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PermissionRequest hooks + Turn hooks on or off. Your changes are saved automatically. + + No hooks installed for this event. + + Press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap new file mode 100644 index 0000000000000000000000000000000000000000..4e7fb4917d5e3b104e43d4c66f0cd3400abe11ae --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + Hooks + Lifecycle hooks from config and enabled plugins. + + Event Installed Active Description + PreToolUse 2 1 Before a tool executes + PermissionRequest 1 1 When permission is requested + PostToolUse 0 0 After a tool executes + PreCompact 0 0 Before context compaction + PostCompact 0 0 After context compaction + SessionStart 0 0 When a new session starts + SessionEnd 0 0 Right before a session ends + UserPromptSubmit 0 0 When the user submits a prompt + SubagentStart 0 0 When a subagent is created + SubagentStop 0 0 Right before a subagent ends its turn + Stop 0 0 Right before Codex ends its turn + Interrupt 0 0 Right before an interrupted turn is aborted + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap new file mode 100644 index 0000000000000000000000000000000000000000..81762244d8411568345d03f65a5cdf3c55d729d8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + Hooks + Lifecycle hooks from config and enabled plugins. + + Issues + ⚠ skipped invalid matcher for PreToolUse + ■ /tmp/hooks.json: failed to parse hooks config + + Event Installed Active Description + PreToolUse 0 0 Before a tool executes + PermissionRequest 0 0 When permission is requested + PostToolUse 0 0 After a tool executes + PreCompact 0 0 Before context compaction + PostCompact 0 0 After context compaction + SessionStart 0 0 When a new session starts + SessionEnd 0 0 Right before a session ends + UserPromptSubmit 0 0 When the user submits a prompt + SubagentStart 0 0 When a subagent is created + SubagentStop 0 0 Right before a subagent ends its turn + Stop 0 0 Right before Codex ends its turn + Interrupt 0 0 Right before an interrupted turn is aborted + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap new file mode 100644 index 0000000000000000000000000000000000000000..4ad2307aff3667b4f5d64698e3faf846b3aa9c76 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + Hooks + Lifecycle hooks from config and enabled plugins. + + ⚠ 1 hook needs review before it can run. + + Event Installed Active Review Description + PreToolUse 1 0 1 Before a tool executes + PermissionRequest 0 0 0 When permission is requested + PostToolUse 0 0 0 After a tool executes + PreCompact 0 0 0 Before context compaction + PostCompact 0 0 0 After context compaction + SessionStart 0 0 0 When a new session starts + SessionEnd 0 0 0 Right before a session ends + UserPromptSubmit 0 0 0 When the user submits a prompt + SubagentStart 0 0 0 When a subagent is created + SubagentStop 0 0 0 Right before a subagent ends its turn + Stop 0 0 0 Right before Codex ends its turn + Interrupt 0 0 0 Right before an interrupted turn is aborted + + Press t to trust all; enter to review hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap new file mode 100644 index 0000000000000000000000000000000000000000..53998ecf72359e6df6298388a5e70e530faeecb0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + [ ] Hook 2 + + Event PreToolUse + Matcher Bash + Source Plugin - superpowers@openai-curated + Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh + Mode Sync + Timeout 30s + Trust Trusted + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap new file mode 100644 index 0000000000000000000000000000000000000000..646741c283fe43850880104cbbc7d15837a28e35 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PermissionRequest hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + + Event PermissionRequest + Matcher Bash + Source Admin config + Command /enterprise/hooks/permission-check.sh + Mode Sync + Timeout 30s + Trust Managed + + Managed hooks are always on; press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_mcp_tool_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_mcp_tool_handler.snap new file mode 100644 index 0000000000000000000000000000000000000000..e3ba7d099c1df766dab6c025f4f31c095322b77f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_mcp_tool_handler.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + + Event PreToolUse + Matcher Bash + Source Plugin - security-tools@openai-curated + MCP Server scanner + MCP Tool scan_file + Timeout 30s + Trust Trusted + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_review_needed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_review_needed_handler.snap new file mode 100644 index 0000000000000000000000000000000000000000..f08bb8cc818f71097a9e96a7ac345b869c2e8c60 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_review_needed_handler.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + 1 hook needs review before it can run. + + [!] Hook 1 · new + + Event PreToolUse + Matcher Bash + Source User config - /tmp/hooks.json + Command /tmp/pre-tool-use-check.sh + Mode Sync + Timeout 30s + Trust New hook - review required + + Press t to trust; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap new file mode 100644 index 0000000000000000000000000000000000000000..5622c6d18841caecb40247bf075b3d118e174855 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap @@ -0,0 +1,26 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 2 + [x] Hook 3 + [x] Hook 4 + [x] Hook 5 + [x] Hook 6 + [x] Hook 7 + [x] Hook 8 + [x] Hook 9 + + Event PreToolUse + Matcher Bash + Source User config - /tmp/hooks.json + Command /tmp/hook-8.sh + Mode Sync + Timeout 30s + Trust Trusted + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap new file mode 100644 index 0000000000000000000000000000000000000000..3884c083b0968ffb799393013ef33c55aa9e724b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 + [x] Hook 2 + + Event PreToolUse + Matcher Bash + Source Admin config + Command /enterprise/hooks/pre-tool-use-2.sh + Mode Sync + Timeout 30s + Trust Managed + + Managed hooks are always on; press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_untrusted_enabled_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_untrusted_enabled_handler.snap new file mode 100644 index 0000000000000000000000000000000000000000..ffd59c8e08b12eeb69fc3991f8e8d465d0160756 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_untrusted_enabled_handler.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + 1 hook needs review before it can run. + + [!] Hook 1 · new + + Event PreToolUse + Matcher Bash + Source User config - /tmp/hooks.json + Command ~/bin/untrusted.sh + Mode Async + Timeout 30s + Trust New hook - review required + + Press t to trust; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_all_rows_scroll.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_all_rows_scroll.snap new file mode 100644 index 0000000000000000000000000000000000000000..1ef9490f8acb29577375f6f3a9835e6a2e126c61 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_all_rows_scroll.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoAllRows, 96)" +--- +before scroll: + + Debug + +› 1. Item 1 desc 1 + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 + + +after scroll: + + Debug + + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 +› 9. Item 9 with an intentionally much longer name desc 9 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_visible_scroll.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_visible_scroll.snap new file mode 100644 index 0000000000000000000000000000000000000000..30bdc4bce1bf4e841d5c28c4822f5e0ff619da44 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_auto_visible_scroll.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_before_after_scroll_snapshot(ColumnWidthMode::AutoVisible, 96)" +--- +before scroll: + + Debug + +› 1. Item 1 desc 1 + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 + + +after scroll: + + Debug + + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 +› 9. Item 9 with an intentionally much longer name desc 9 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap new file mode 100644 index 0000000000000000000000000000000000000000..7be4a155952072c864f378cd0cbb625f9dda59bc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_col_width_mode_fixed_scroll.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_before_after_scroll_snapshot(ColumnWidthMode::Fixed, 96)" +--- +before scroll: + + Debug + +› 1. Item 1 desc 1 + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 + + +after scroll: + + Debug + + 2. Item 2 desc 2 + 3. Item 3 desc 3 + 4. Item 4 desc 4 + 5. Item 5 desc 5 + 6. Item 6 desc 6 + 7. Item 7 desc 7 + 8. Item 8 desc 8 +› 9. Item 9 with an intent… desc 9 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap new file mode 100644 index 0000000000000000000000000000000000000000..a69e588ec5617d94f24508e45e614e27342493b5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_empty_searchable.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: rendered +--- + + Select a base branch + + Type to search branches + no matches + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap new file mode 100644 index 0000000000000000000000000000000000000000..8eb41f417f9ee38d43ef7746a51e487d2691ecea --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 40)" +--- + + Select Approval Mode + +› 1. Read Only (current) Codex can + read files + + Note: Use /setup-default-sandbox to + allow network access. + Press enter to confirm or esc to go ba diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_halfwidth_sound_marks_narrow.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_halfwidth_sound_marks_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..81badbe2435cf2498db9d23a45f359628b5dd25f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_halfwidth_sound_marks_narrow.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: rendered +--- +width 20: + + Halfwidth sound + +› 1. abガc + dakute + n + descri + ption + 2. aパc + handak + uten + descri + ption + + +width 24: + + Halfwidth sound mark + +› 1. abガc dakuten + descriptio + n + 2. aパc handakuten + descriptio + n diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap new file mode 100644 index 0000000000000000000000000000000000000000..be81978c896edf3fce8945a1b1c7734066a746cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 80)" +--- + + Select Model and Effort + +› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning + quality and coding ability. + 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less + capable. + 3. gpt-4.1-codex Legacy model. Use when you need compatibility + with older automations. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap new file mode 100644 index 0000000000000000000000000000000000000000..3ce6a3c45ff28c49d8851b5d84ce9ff4597ab965 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: "render_lines_with_width(&view, 24)" +--- + + Debug + +› 1. Item 1 + xxxxxxxxx + x + 2. Item 2 + xxxxxxxxx + x + 3. Item 3 + xxxxxxxxx + x diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap new file mode 100644 index 0000000000000000000000000000000000000000..512f6bbca632a13e7e2c87790a55399f14597c5c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: render_lines(&view) +--- + + Select Approval Mode + Switch between Codex approval presets + +› 1. Read Only (current) Codex can read files + 2. Full Access Codex can edit files + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap new file mode 100644 index 0000000000000000000000000000000000000000..ddd0f90cd87e531e685c5bca11f30d8c54a8e57e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +expression: render_lines(&view) +--- + + Select Approval Mode + +› 1. Read Only (current) Codex can read files + 2. Full Access Codex can edit files + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap new file mode 100644 index 0000000000000000000000000000000000000000..0ac8f529ad3b4633cff3f5e800e5c6b246741223 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_param_summary.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" +--- + + Field 1/1 + Allow Calendar to create an event + + Calendar: primary + Title: Roadmap review + Notes: This is a deliberately long note that should truncate bef... + + › 1. Allow Run the tool and continue. + 2. Cancel Cancel this tool call + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_session_persist.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_session_persist.snap new file mode 100644 index 0000000000000000000000000000000000000000..b8bb8f001c262ed1a1bdcb28d2abb8b7764eab28 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_with_session_persist.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" +--- + + Field 1/1 + Allow this request? + › 1. Allow Run the tool and continue. + 2. Allow for this session Run the tool and remember this choice for this session. + 3. Always allow Run the tool and remember this choice for future tool calls. + 4. Cancel Cancel this tool call + + + + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_without_schema.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_without_schema.snap new file mode 100644 index 0000000000000000000000000000000000000000..2d1c33fcbf90e086fdc9f725e3304a2f2ae8075f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_approval_form_without_schema.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" +--- + + Field 1/1 + Allow this request? + › 1. Allow Run the tool and continue. + 2. Cancel Cancel this tool call + + + + + + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap new file mode 100644 index 0000000000000000000000000000000000000000..0415c2407142d88b6c53e2a93242a6552174dcd4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_boolean_form.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" +--- + + Field 1/1 (1 required unanswered) + Allow this request? + + Confirm + Approve the pending action. + › 1. True + 2. False + + + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_message_only_form.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_message_only_form.snap new file mode 100644 index 0000000000000000000000000000000000000000..e51ff1a6fac6849686d331bd9266cdc98df6a399 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_message_only_form.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" +--- + + Field 1/1 + Boolean elicit MCP example: do you confirm? + › 1. Allow Allow this request and continue. + 2. Deny Decline this request and continue. + 3. Cancel Cancel this request + + + + + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_message_only_form_with_persist_options.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_message_only_form_with_persist_options.snap new file mode 100644 index 0000000000000000000000000000000000000000..0a2d5d2f9d57df4601ea10fe3a7a33aea7c03d62 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_message_only_form_with_persist_options.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))" +--- + + Field 1/1 + Boolean elicit MCP example: do you confirm? + › 1. Allow Allow this request and continue. + 2. Allow for this session Allow this request and remember this choice for this session. + 3. Always allow Allow this request and remember this choice for future requests. + 4. Deny Decline this request and continue. + 5. Cancel Cancel this request + + + + + + + enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_text_configured_key_chords.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_text_configured_key_chords.snap new file mode 100644 index 0000000000000000000000000000000000000000..4a1435d83830b5423cad3200bf2c9b66f1721502 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__mcp_server_elicitation__tests__mcp_server_elicitation_text_configured_key_chords.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/mcp_server_elicitation.rs +expression: snapshot +--- + + Field 1/1 (1 required unanswered) + Enter a name + + Name + + › Type your answer + + ctrl + x enter to submit | esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..cf1f7248b322947dca97406c78aeb35f98303ae4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + " ↳ This is ", + " a message ", + " with many ", + " … ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..5e403e1bddfa628aaf52994d266f7f7a87bb1c83 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 2 }, + content: [ + " ↳ Hello, world! ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap new file mode 100644 index 0000000000000000000000000000000000000000..4484509695bd197174187a82b7df9d550f18b45f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ↳ Hello, world! ", + " ↳ This is another message ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..16d636125743d0dc5b32d0da4bd10a89cfb1e228 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/message_queue.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " alt + ↑ edit ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..65c011c26b1227f888ec2c7712591d1c2eee3e48 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_many_line_message.snap @@ -0,0 +1,31 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 282 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 6 }, + content: [ + "• Queued follow-up inputs ", + " ↳ This is ", + " a message ", + " with many ", + " … ", + " ⌥ + ↑ edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 11, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 5, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap new file mode 100644 index 0000000000000000000000000000000000000000..f2506a3646506e1a4b98f555cd950dbaedbba122 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_more_than_three_messages.snap @@ -0,0 +1,34 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 253 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 6 }, + content: [ + "• Queued follow-up inputs ", + " ↳ Hello, world! ", + " ↳ This is another message ", + " ↳ This is a third message ", + " ↳ This is a fourth message ", + " ⌥ + ↑ edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 28, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_multiline_pending_steer_uses_single_prefix_and_truncates.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_multiline_pending_steer_uses_single_prefix_and_truncates.snap new file mode 100644 index 0000000000000000000000000000000000000000..be89f767a8c4edade3f76d629485b20a8d14d60b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_multiline_pending_steer_uses_single_prefix_and_truncates.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 6 }, + content: [ + "• Messages to be submitted after next tool call ", + " (press esc to interrupt and send immediately) ", + " ↳ First line ", + " Second line ", + " Third line ", + " … ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 15, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 5, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..74e39ed514713c4e5a8bf1c02ec7714327e933a5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 204 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up inputs ", + " ↳ Hello, world! ", + " ⌥ + ↑ edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap new file mode 100644 index 0000000000000000000000000000000000000000..c79ee32506ad8f74b4d7eeedf99265b6c7bf8f1b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_message_with_shift_left_binding.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 216 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + "• Queued follow-up inputs ", + " ↳ Hello, world! ", + " shift + ← edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap new file mode 100644 index 0000000000000000000000000000000000000000..9a8e3b96d10b92417b151a53cbb68c3ad2d8e93d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 3 }, + content: [ + "• Messages to be submitted after next tool call ", + " (press esc to interrupt and send immediately) ", + " ↳ Please continue. ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap new file mode 100644 index 0000000000000000000000000000000000000000..f8a3caa1bf09a1b6efb2d6fa8a72e50dc0fb3662 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 3 }, + content: [ + "• Messages to be submitted after next tool call ", + " (press f12 to interrupt and send immediately) ", + " ↳ Please continue. ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap new file mode 100644 index 0000000000000000000000000000000000000000..0321b79d6bdc66e66211c5936728f70b56e92908 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_pending_steers_above_queued_messages.snap @@ -0,0 +1,42 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 345 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 52, height: 11 }, + content: [ + "• Messages to be submitted after next tool call ", + " (press esc to interrupt and send immediately) ", + " ↳ Please continue. ", + " ↳ Check the last command output. ", + " ", + "• Messages to be submitted at end of turn ", + " ↳ Rejected steer that will be retried. ", + " ", + "• Queued follow-up inputs ", + " ↳ Queued follow-up question ", + " ⌥ + ↑ edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 40, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 29, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap new file mode 100644 index 0000000000000000000000000000000000000000..90c4fea4a0098e38659cdadc970fb3f7577dd74b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_two_messages.snap @@ -0,0 +1,26 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 233 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 4 }, + content: [ + "• Queued follow-up inputs ", + " ↳ Hello, world! ", + " ↳ This is another message ", + " ⌥ + ↑ edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap new file mode 100644 index 0000000000000000000000000000000000000000..b338f880acca8debe9711453af7dd0bcfcc2064e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_wrapped_message.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +assertion_line: 269 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 5 }, + content: [ + "• Queued follow-up inputs ", + " ↳ This is a longer message that should", + " be wrapped ", + " ↳ This is another message ", + " ⌥ + ↑ edit last queued message ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, + x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 34, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__question_tests__question_collapsed_countdown.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__question_tests__question_collapsed_countdown.snap new file mode 100644 index 0000000000000000000000000000000000000000..e2c7d1f56e8a47c7ee139fbab473ff23d8655766 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__question_tests__question_collapsed_countdown.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/questions_tests.rs +expression: "pane.question_summary(now).unwrap().iter().map(ToString::to_string).collect::>().join(\"\\n\")" +--- + ? 2 questions · 15s + ⌥ + ↑ to answer diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__selection_popup_common__tests__popup_fuzzy_matched_emoji_graphemes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__selection_popup_common__tests__popup_fuzzy_matched_emoji_graphemes.snap new file mode 100644 index 0000000000000000000000000000000000000000..22bd1bfdcbe44ff74ad5e05c32117b3c4a4cb620 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__selection_popup_common__tests__popup_fuzzy_matched_emoji_graphemes.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/selection_popup_common.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 20, height: 2 }, + content: [ + "👍🏻 description ", // hidden by multi-width symbols: [(1, " ")] + "👨‍👩‍👧‍👦 description ", // hidden by multi-width symbols: [(1, " ")] + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 1, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 15, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 1, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 15, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skill_popup__tests__skill_popup_lowercase_expansion.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skill_popup__tests__skill_popup_lowercase_expansion.snap new file mode 100644 index 0000000000000000000000000000000000000000..c88b3aa5c28ee0d068375ee683b9b26313600644 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skill_popup__tests__skill_popup_lowercase_expansion.snap @@ -0,0 +1,36 @@ +--- +source: tui/src/bottom_pane/skill_popup.rs +expression: "render_popup(&popup, 48)" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 6 }, + content: [ + " ai̇x [Skill] ", + " aİx [Skill] ", + " i̇x [Skill] ", + " İx [Skill] ", + " ", + " Press enter to insert or esc to close ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 7, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 7, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 8, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 13, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 27, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 30, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skill_popup__tests__skill_popup_scrolled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skill_popup__tests__skill_popup_scrolled.snap new file mode 100644 index 0000000000000000000000000000000000000000..00789eadc1b73f74672d89cb0b9704c93df7d6f4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skill_popup__tests__skill_popup_scrolled.snap @@ -0,0 +1,43 @@ +--- +source: tui/src/bottom_pane/skill_popup.rs +assertion_line: 289 +expression: "render_popup(&popup, /*width*/ 72)" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 72, height: 10 }, + content: [ + " Mention 01 [Skill] Description 01 ", + " Mention 02 [Skill] Description 02 ", + " Mention 03 [Skill] Description 03 ", + " Mention 04 [Skill] Description 04 ", + " Mention 05 [Skill] Description 05 ", + " Mention 06 [Skill] Description 06 ", + " Mention 07 [Skill] Description 07 ", + " Mention 08 [Skill] Description 08 ", + " ", + " Press enter to insert or esc to close ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 36, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 36, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 36, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 36, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 36, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 14, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 36, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 7, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, + x: 36, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 8, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 13, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 27, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 30, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_basic.snap new file mode 100644 index 0000000000000000000000000000000000000000..e5984581a40519a17865f7c36edc81054fa7a905 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_basic.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/skills_toggle_view.rs +expression: "render_lines(&view, 72)" +--- + + Enable/Disable Skills + Turn skills on or off. Your changes are saved automatically. + + Type to search skills + > +› [x] Repo Scout Summarize the repo layout + [ ] Changelog Writer Draft release notes + + Press space or enter to toggle; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_long_names_at_narrow_width.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_long_names_at_narrow_width.snap new file mode 100644 index 0000000000000000000000000000000000000000..d2e4dc3c5f7186aecf6498ecf4764a84e1363dca --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_long_names_at_narrow_width.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/skills_toggle_view.rs +expression: "render_lines(&view, 48)" +--- + + Enable/Disable Skills + Turn skills on or off. Your changes are save + + Type to search skills + > +› [x] superpowers-systematic-d… Find root cau… + [ ] superpowers-verification… Verify comple… + + Press space or enter to toggle; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_long_names_use_available_width.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_long_names_use_available_width.snap new file mode 100644 index 0000000000000000000000000000000000000000..e2f2f67a7c751ee35451d39609a9f64164385e18 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__skills_toggle_view__tests__skills_toggle_long_names_use_available_width.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/skills_toggle_view.rs +expression: "render_lines(&view, 96)" +--- + + Enable/Disable Skills + Turn skills on or off. Your changes are saved automatically. + + Type to search skills + > +› [x] superpowers-systematic-debugging (polish) Find root causes before fixing bugs + [ ] superpowers-verification-before-completion (polish) Verify completion before claiming … + + Press space or enter to toggle; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_includes_hostname.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_includes_hostname.snap new file mode 100644 index 0000000000000000000000000000000000000000..48a0a8a176abf2e42085a47c3672d0d862ddfbe2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_includes_hostname.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/status_line_setup.rs +expression: "render_lines(&view,\n100).lines().map(str::trim_end).collect::>().join(\"\\n\")" +--- + + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] hostname Current machine hostname (omitted when unavailable) + [x] current-dir Current working directory + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] reasoning Current reasoning level + [ ] project-name Project name (omitted when unavailable) + + ssh-build-01.example.com · ~/codex-rs + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_includes_thread_usage_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_includes_thread_usage_items.snap new file mode 100644 index 0000000000000000000000000000000000000000..813828171f6fb20c45e89e38bbf76c9195bbb4ef --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_includes_thread_usage_items.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/status_line_setup.rs +expression: "render_lines(&view, 100)" +--- + + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] thread-credits Estimated current-thread credits (Enterprise workspaces only; omitte… + [x] estimated-thread-cost Estimated current-thread cost in USD (Enterprise workspaces only; om… + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] reasoning Current reasoning level + [ ] current-dir Current working directory + + 5.2 credits · ~$1.82 + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap new file mode 100644 index 0000000000000000000000000000000000000000..132ce3dd23bd9a16ffaf96e9814cc377a9f54d61 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__status_line_setup__tests__setup_view_snapshot_uses_runtime_preview_values.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/status_line_setup.rs +expression: "render_lines(&view, 72)" +--- + + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] model Current model name + [x] current-dir Current working directory + [x] git-branch Current Git branch (omitted when unavaila… + [ ] model-with-reasoning Current model name with reasoning level + [ ] reasoning Current reasoning level + [ ] project-name Project name (omitted when unavailable) + + gpt-5-codex · ~/codex-rs · jif/statusline-preview + Press space to toggle; ←/→ to move; enter to confirm and close; esc to diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap new file mode 100644 index 0000000000000000000000000000000000000000..1b903573562857f54df2d38c45ab76f029d1bdb5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_personal_limit.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + + Usage limit reached + Your included usage is depleted. + Choose an action to continue. +› 1. Open usage settings + 2. View usage + 3. Extra action + + Press a number to choose · esc to dismiss + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap new file mode 100644 index 0000000000000000000000000000000000000000..faf38fd585ae8fc4f679eff6861a9dcd3767f0cd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__backend_banner_workspace_member_credits.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + + Usage limit reached + Your included usage is depleted. + Choose an action to continue. +› 1. Open usage settings + 2. Notify owner + 3. Extra action + + Press a number to choose · esc to dismiss + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap new file mode 100644 index 0000000000000000000000000000000000000000..04967785ba9e82d6226f03ada654cccfbd814950 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__inline_banner_wrapped_and_truncated.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- + + Choose how to continue working + A long description wraps to fit the + available terminal width. + A long description wraps to fit the + available terminal width. + A long description wraps to fit the + available terminal width. + … +› 1. Open usage settings + 2. Notify owner + + Press a number to choose · esc to dismiss + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..dbaad6344266220c86f70829960a73d31deae028 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/mod.rs +assertion_line: 1858 +expression: "render_snapshot(&pane, area)" +--- +• Queued follow-up inputs + ↳ Queued follow-up question + ⌥ + ↑ edit last queued message + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__slash_command_popup_dismissed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__slash_command_popup_dismissed.snap new file mode 100644 index 0000000000000000000000000000000000000000..2eae5af3d3714db0caab0a9531ead1a2aea1c478 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__slash_command_popup_dismissed.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + + +› /rev + + tab to queue message 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap new file mode 100644 index 0000000000000000000000000000000000000000..5c95c9f811dad24df7eae1604caeec675b86995a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interr… + + +› Ask Codex to do anything + + 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..94f462d835b1b367e9d331a2a5b6cf5c1068413a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/mod.rs +assertion_line: 1889 +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + +• Queued follow-up inputs + ↳ Queued follow-up question + ⌥ + ↑ edit last queued message + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap new file mode 100644 index 0000000000000000000000000000000000000000..52f96e8557ab5f8099b88d82b9ba1de59cf79376 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area1)" +--- +› Ask Codex to do a diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..136c35805547d31ef46c45738e07fa28e02ca76d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/mod.rs +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_timer_survives_hidden_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_timer_survives_hidden_row.snap new file mode 100644 index 0000000000000000000000000000000000000000..13ce9387f4148f0e7b0dbb4fb818fcac4018ed56 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_timer_survives_hidden_row.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/mod.rs +assertion_line: 2570 +expression: "render_snapshot(&pane, area)" +--- +• Working (42s • esc to interrupt) + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..4e8763c83dc15e6886d5c3f401808758a6822971 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/mod.rs +assertion_line: 1826 +expression: "render_snapshot(&pane, area)" +--- +• Working (0s • esc to interrupt) + └ First detail line + Second detail line + +• Queued follow-up inputs + ↳ Queued follow-up question + ⌥ + ↑ edit last queued message + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_breakable_unicode_space_hangs_before_following_text.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_breakable_unicode_space_hangs_before_following_text.snap new file mode 100644 index 0000000000000000000000000000000000000000..5fad1f50fa580d7df58ec673caa9e9ee7b374a26 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_breakable_unicode_space_hangs_before_following_text.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((1, 2)) +"abad" +"abcd" +"e " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_final_line_scrolls_to_visible_cursor_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_final_line_scrolls_to_visible_cursor_row.snap new file mode 100644 index 0000000000000000000000000000000000000000..2e7df724b41dafb2979c9727afaac799d0cc1a8f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_final_line_scrolls_to_visible_cursor_row.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"scroll: {}\\ncursor: {:?}\\n{}\", state.scroll,\nt.cursor_pos_with_state(area, state), terminal.backend())" +--- +scroll: 1 +cursor: Some((0, 1)) +"efgh" +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_non_final_line_has_a_visible_cursor_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_non_final_line_has_a_visible_cursor_row.snap new file mode 100644 index 0000000000000000000000000000000000000000..1984373cbf5a1845524154cef1df410159ce286c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_full_non_final_line_has_a_visible_cursor_row.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((0, 1)) +"abad" +" " +"ef " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_halfwidth_sound_marks_wrap_and_align_with_cursor.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_halfwidth_sound_marks_wrap_and_align_with_cursor.snap new file mode 100644 index 0000000000000000000000000000000000000000..403d087188ee4762698b61ee2e1edec91fdaae71 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_halfwidth_sound_marks_wrap_and_align_with_cursor.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +dakuten +cursor: Some((1, 1)) +"12ガ" Hidden by multi-width symbols: [(3, " ")] +"x " + + +handakuten +cursor: Some((0, 2)) +"ab " +"パc" Hidden by multi-width symbols: [(1, " ")] +" " + + +standalone +cursor: Some((0, 2)) +"a " +"゙b" +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_reflows_across_following_wrapped_fragments.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_reflows_across_following_wrapped_fragments.snap new file mode 100644 index 0000000000000000000000000000000000000000..b9e92f36bc5ca1a46228d96186b4a737e9570a78 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_leading_space_reflows_across_following_wrapped_fragments.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((2, 3)) +"abad" +"abcd" +"e xy" +"zz " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_masked_graphemes_align_with_cursor.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_masked_graphemes_align_with_cursor.snap new file mode 100644 index 0000000000000000000000000000000000000000..2b3b4ec43edf7de0f6c870dc021f637379e0ebc5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_masked_graphemes_align_with_cursor.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((0, 1)) +"****" +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_keeps_fitting_remainder_on_current_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_keeps_fitting_remainder_on_current_row.snap new file mode 100644 index 0000000000000000000000000000000000000000..7c8cdef02036b13a32e86bd6b2b944a395de2c51 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_keeps_fitting_remainder_on_current_row.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((3, 1)) +"p " +"a b " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_preserves_hyphenated_word_breakpoint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_preserves_hyphenated_word_breakpoint.snap new file mode 100644 index 0000000000000000000000000000000000000000..fbc9a5ce147f00ac214f144e2b26e8dc443a6139 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_reflow_preserves_hyphenated_word_breakpoint.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((6, 1)) +"a foo- " +"barbaz " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_separator_after_full_line_hangs_before_following_text.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_separator_after_full_line_hangs_before_following_text.snap new file mode 100644 index 0000000000000000000000000000000000000000..3f81d108b145610d6b44e0b7568ef64442134445 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_separator_after_full_line_hangs_before_following_text.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((1, 1)) +"abad" +"a " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap new file mode 100644 index 0000000000000000000000000000000000000000..35501979d988410de5d7d99e71e3f7eb32c46f0b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_styled_overlays_stay_inside_the_textarea.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +element +"abadKEEP" +"next " +" " + + +highlight +"abadKEEP" +"next " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_tabs_render_as_spaces_and_align_with_cursor.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_tabs_render_as_spaces_and_align_with_cursor.snap new file mode 100644 index 0000000000000000000000000000000000000000..f8fe7a1ce8b1fdca8ad643a9a402c9f3d1d6bf8a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_tabs_render_as_spaces_and_align_with_cursor.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {cursor:?}\\n{}\", terminal.backend())" +--- +cursor: (62, 0) +"❌ Simulation formatter[large/dataset.py] 7.4 ms 8.1 ms -8.29% " Hidden by multi-width symbols: [(1, " ")] diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_thai_backspace_preserves_remaining_marks.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_thai_backspace_preserves_remaining_marks.snap new file mode 100644 index 0000000000000000000000000000000000000000..4d276f81c550da9c15c7331257e992edc4f828a9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_thai_backspace_preserves_remaining_marks.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((1, 0)) +"ที " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_trailing_spaces_wrap_without_cursor_overflow.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_trailing_spaces_wrap_without_cursor_overflow.snap new file mode 100644 index 0000000000000000000000000000000000000000..e192d6d73de897b701fc2e1a1dce9e50690218da --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__textarea_trailing_spaces_wrap_without_cursor_overflow.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "format!(\"cursor: {:?}\\n{}\", t.cursor_pos(area), terminal.backend())" +--- +cursor: Some((2, 2)) +"abad " +" " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap new file mode 100644 index 0000000000000000000000000000000000000000..0a41c2d80fdb3afaafd3469866138d35eb01ef17 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "states.join(\"\\n\\n\")" +--- +alpha beta gamma + ^ + +alpha beta gamma + ^ + +alpha beta gamma + ^ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap new file mode 100644 index 0000000000000000000000000000000000000000..dfdcbdf8874b63b42aef85f551934f541bb6687b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__title_setup__tests__terminal_title_setup_basic.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/title_setup.rs +expression: "render_lines(&view, 84)" +--- + + Configure Terminal Title + Select which items to display in the terminal title. + + Type to search + > +› [x] project-name Project name (falls back to current directory name) + [x] activity Spinner while working, action-required message while blocked. + [x] run-state Compact session run-state text (Ready, Working, Thinking) + [x] thread-title Current thread title, or thread identifier when unnamed + [ ] app-name Codex app name + [ ] current-dir Current working directory + [ ] thread-name Current thread name (omitted when unnamed) + [ ] git-branch Current Git branch (omitted when unavailable) + + [ ! ] Action Required | my-project | Working | thread title + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap new file mode 100644 index 0000000000000000000000000000000000000000..b1c3b5919a2cbafe36eb34cd437cb3c7f5d2b886 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/unified_exec_footer.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 50, height: 1 }, + content: [ + " 123 background terminals running · /ps to view ·", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap new file mode 100644 index 0000000000000000000000000000000000000000..e9820815fe13214dceebeeb54139f853148a4fe8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/unified_exec_footer.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 50, height: 1 }, + content: [ + " 1 background terminal running · /ps to view · /s", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap new file mode 100644 index 0000000000000000000000000000000000000000..3aac1636d8c8711d78bc2f611c4e54d258ef26e7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_full_screen_bottom.snap @@ -0,0 +1,27 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 12 }, + content: [ + "/ USER VERIFICATION / / / / / / / / / / ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Deployment details. Deployment details. ", + "Final approval details. ", + "───────────────────────────────── 100% ─", + " ↑/↓ to scroll pgup/pgdn to page hom", + " q close ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 8, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap new file mode 100644 index 0000000000000000000000000000000000000000..0bb6e75c6192ce2f1dc995a33fc90c6007e8d465 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_long_urls.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "render_view_lines(&view, 30)" +--- + + Approve + https://example.com/produc + tion/release/candidate? + + Server: deployments + + Deploy + https://example.com/produc + tion/critical-release-cand + idate + +› 1. Verify and approve (y) + 2. Cancel this request (c) + + Press enter to confirm or + esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..730037a3064a3faab25b32419ec397f3e6a5d4d7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_narrow.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "render_view_lines(&view, 20)" +--- + + Production + deployment needs + your approval. + + Server: + deployments + + Approve + deploying the + reviewed + release? + +› 1. Verify and + approve (y) + 2. Cancel this + request (c) + + Press enter to + confirm or esc to + cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..006d65c8aab320799dfaa3203cf81a66bc41b33e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_prompt.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "render_view_lines(&view, 80)" +--- + + Production deployment needs your approval. + + Server: deployments + + Approve deploying the reviewed release? + +› 1. Verify and approve (y) + 2. Cancel this request (c) + + Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_waiting.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_waiting.snap new file mode 100644 index 0000000000000000000000000000000000000000..008672beb50043382ca505cf40da03f8194369bc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__user_verification__tests__user_verification_waiting.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/user_verification_tests.rs +expression: "render_view_lines(&view, 80)" +--- +Waiting for verification… + +Server: deployments + +Approve deploying the reviewed release? + +Press esc to cancel this request diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__voice_strip__tests__voice_controls_follow_configured_mute_binding_and_unbinding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__voice_strip__tests__voice_controls_follow_configured_mute_binding_and_unbinding.snap new file mode 100644 index 0000000000000000000000000000000000000000..5f1bfe8d19ae0e24e733c2f8b277bc1b2e92fca2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__voice_strip__tests__voice_controls_follow_configured_mute_binding_and_unbinding.snap @@ -0,0 +1,52 @@ +--- +source: tui/src/bottom_pane/voice_strip_tests.rs +assertion_line: 165 +expression: "layouts.join(\"\\n\\n\")" +--- +'f8', muted=false, width=80: + voice ● listening f8 mute /voice stop + mic ▆▅▄▃▂▁ codex ▁▃▄▅▆█ + +'f8', muted=false, width=22: + voice /voice stop + mic ▃▂▁ codex ▅▆█ + +'f8', muted=true, width=80: + voice ◌ muted f8 unmute /voice stop + mic ▁▁▁▁▁▁ codex ▁▃▄▅▆█ + +'f8', muted=true, width=22: + voice /voice stop + mic ▁▁▁ codex ▅▆█ + +'ctrl-x m', muted=false, width=80: + voice ● listening ctrl+x m mute /voice stop + mic ▆▅▄▃▂▁ codex ▁▃▄▅▆█ + +'ctrl-x m', muted=false, width=22: + voice /voice stop + mic ▃▂▁ codex ▅▆█ + +'ctrl-x m', muted=true, width=80: + voice ◌ muted ctrl+x m unmute /voice stop + mic ▁▁▁▁▁▁ codex ▁▃▄▅▆█ + +'ctrl-x m', muted=true, width=22: + voice /voice stop + mic ▁▁▁ codex ▅▆█ + +[], muted=false, width=80: + voice ● listening /voice mute /voice stop + mic ▆▅▄▃▂▁ codex ▁▃▄▅▆█ + +[], muted=false, width=22: + voice /voice stop + mic ▃▂▁ codex ▅▆█ + +[], muted=true, width=80: + voice ◌ muted /voice mute /voice stop + mic ▁▁▁▁▁▁ codex ▁▃▄▅▆█ + +[], muted=true, width=22: + voice /voice stop + mic ▁▁▁ codex ▅▆█ diff --git a/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs b/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..98646682b4a8511c56f9b0251280aecb94468a63 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/actionable_banner_tests.rs @@ -0,0 +1,170 @@ +use super::super::actionable_banner::BannerDismissal; +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn hidden_banner_preserves_input_before_render_and_after_resize() { + let (tx, mut rx) = unbounded_channel(); + let mut pane = test_pane_with_disable_paste_burst( + AppEventSender::new(tx), + /*disable_paste_burst*/ true, + ); + pane.set_inline_banner(Some(ActionableBanner { + title: "Usage limit reached".into(), + actions: vec![SelectionItem { + name: "View usage".into(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: "https://example.com/usage".into(), + }); + })], + ..Default::default() + }], + ..Default::default() + })); + let width = 70; + let hidden_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.composer.desired_height(width), + ); + let visible_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + + // Keys remain composer input before the first frame and after resizing a + // previously visible banner out of view. + for render_hidden in [false, true] { + if render_hidden { + let rendered = render_snapshot(&pane, hidden_area); + assert!(!rendered.contains("View usage")); + } + pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), "1"); + assert!(rx.try_recv().is_err()); + pane.set_composer_text(String::new(), Vec::new(), Vec::new()); + assert!(pane.is_normal_backtrack_mode()); + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let rendered = render_snapshot(&pane, visible_area); + assert!(rendered.contains("View usage")); + assert!(!pane.is_normal_backtrack_mode()); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), ""); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::OpenUrlInBrowser { url }) + if url == "https://example.com/usage") + ); + } + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(pane.is_normal_backtrack_mode()); + assert!(!render_snapshot(&pane, visible_area).contains("View usage")); +} + +#[test] +fn partially_clipped_banner_preserves_hidden_action_shortcuts() { + let (tx, mut rx) = unbounded_channel(); + let mut pane = test_pane_with_disable_paste_burst( + AppEventSender::new(tx), + /*disable_paste_burst*/ true, + ); + pane.set_inline_banner(Some(ActionableBanner { + title: "Usage limit reached".into(), + actions: vec![SelectionItem { + name: "View usage".into(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenUrlInBrowser { + url: "https://example.com/usage".into(), + }); + })], + ..Default::default() + }], + ..Default::default() + })); + let width = 70; + let footer_only_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.composer.desired_height(width) + 1, + ); + + let rendered = render_snapshot(&pane, footer_only_area); + assert!(!rendered.contains("View usage")); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), "1"); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn banner_hidden_during_task_waits_for_redraw_before_accepting_dismissal() { + let (tx, _rx) = unbounded_channel(); + let mut pane = test_pane(AppEventSender::new(tx)); + pane.set_inline_banner(Some(ActionableBanner { + title: "Usage limit reached".into(), + ..Default::default() + })); + let width = 70; + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + pane.desired_height(width), + ); + assert!(render_snapshot(&pane, area).contains("Usage limit reached")); + + pane.set_task_running(/*running*/ true); + assert!(!render_snapshot(&pane, area).contains("Usage limit reached")); + pane.set_task_running(/*running*/ false); + assert!(pane.is_normal_backtrack_mode()); + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(render_snapshot(&pane, area).contains("Usage limit reached")); + assert!(!pane.is_normal_backtrack_mode()); +} + +#[test] +fn information_banner_preserves_input_and_honors_dismissal() { + for dismissal in [BannerDismissal::Persistent, BannerDismissal::Dismissible] { + let (tx, _rx) = unbounded_channel(); + let mut pane = test_pane_with_disable_paste_burst( + AppEventSender::new(tx), + /*disable_paste_burst*/ true, + ); + pane.set_inline_banner(Some(ActionableBanner { + title: "Usage limit reached".into(), + description: "You can change models or request more usage.".into(), + dismissal, + ..Default::default() + })); + let area = Rect::new( + /*x*/ 0, + /*y*/ 0, + /*width*/ 70, + pane.desired_height(/*width*/ 70), + ); + let rendered = render_snapshot(&pane, area); + assert!(!rendered.contains("no matches")); + assert!(!rendered.contains("Press a number")); + assert_eq!( + rendered.contains("esc to dismiss"), + dismissal == BannerDismissal::Dismissible + ); + if dismissal == BannerDismissal::Persistent { + assert_snapshot!("information_banner_persistent", rendered); + } else { + assert_snapshot!("information_banner_dismissible", rendered); + } + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + render_snapshot(&pane, area).contains("Usage limit reached"), + dismissal == BannerDismissal::Persistent + ); + pane.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!(pane.composer_text(), "1"); + } +} diff --git a/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_dismissible.snap b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_dismissible.snap new file mode 100644 index 0000000000000000000000000000000000000000..9c624f04677b3539d4edc503b5c468c07f91db48 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_dismissible.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/bottom_pane/tests/actionable_banner_tests.rs +assertion_line: 34 +expression: rendered +--- + + Usage limit reached + You can change models or request more usage. + + esc to dismiss · type to continue + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_persistent.snap b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_persistent.snap new file mode 100644 index 0000000000000000000000000000000000000000..f717e24265162e65dce97f25b390a853ddbcf78c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/tests/snapshots/codex_tui__bottom_pane__tests__actionable_banner_tests__information_banner_persistent.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/tests/actionable_banner_tests.rs +assertion_line: 32 +expression: rendered +--- + + Usage limit reached + You can change models or request more usage. + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/bottom_pane/textarea/hyperlinks.rs b/codex-rs/tui/src/bottom_pane/textarea/hyperlinks.rs new file mode 100644 index 0000000000000000000000000000000000000000..afd1c878202b2dc09e3ea2af00cdf148171dcbfa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/hyperlinks.rs @@ -0,0 +1,98 @@ +//! Preserve complete web destinations on visible, independently wrapped textarea rows. + +use super::text_for_display; +use super::wrapping::visible_prefix; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::TerminalHyperlink; +use crate::terminal_hyperlinks::mark_buffer_hyperlinks; +use crate::terminal_hyperlinks::web_links_in_text; +use crate::width::display_width; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::Line; +use std::ops::Range; + +/// Destinations and row offsets retained for one lazily initialized textarea wrap state. +#[derive(Clone, Debug, Default)] +pub(super) struct HyperlinkCache { + hyperlinks: Vec, + source_columns: Vec, +} + +impl HyperlinkCache { + /// Scan the draft once and precompute row offsets for constant-time scrolled lookup. + pub(super) fn new(text: &str, lines: &[Range]) -> Self { + let display_text = text_for_display(text); + let hyperlinks = web_links_in_text(display_text.as_ref()); + if hyperlinks.is_empty() { + return Self::default(); + } + + let mut source_column = 0; + let mut previous_start = 0; + let source_columns = lines + .iter() + .map(|line| { + source_column += display_width(&display_text[previous_start..line.start]); + previous_start = line.start; + source_column + }) + .collect(); + + Self { + hyperlinks, + source_columns, + } + } + + /// Attach complete destinations after styling without revisiting offscreen draft text. + pub(super) fn mark( + &self, + buf: &mut Buffer, + area: Rect, + text: &str, + lines: &[Range], + visible_rows: Range, + ) { + if area.width == 0 || area.height == 0 || self.hyperlinks.is_empty() { + return; + } + + for (row, index) in visible_rows.enumerate() { + let line_range = &lines[index]; + let source_column = self.source_columns[index]; + let raw_visible = visible_prefix( + &text[line_range.start..line_range.end.saturating_sub(1)], + area.width, + ); + let visible = text_for_display(raw_visible); + let line_end = source_column + display_width(visible.as_ref()); + let first_hyperlink = self + .hyperlinks + .partition_point(|hyperlink| hyperlink.columns.end <= source_column); + + let mut annotated = HyperlinkLine::new(Line::from(visible.into_owned())); + for hyperlink in self.hyperlinks[first_hyperlink..] + .iter() + .take_while(|hyperlink| hyperlink.columns.start < line_end) + { + let start = hyperlink.columns.start.max(source_column) - source_column; + let end = hyperlink.columns.end.min(line_end) - source_column; + annotated.hyperlinks.push(TerminalHyperlink::web( + start..end, + hyperlink.destination.clone(), + )); + } + + if !annotated.hyperlinks.is_empty() { + let row_area = + Rect::new(area.x, area.y + row as u16, area.width, /*height*/ 1); + mark_buffer_hyperlinks(buf, row_area, &[annotated], /*scroll_rows*/ 0); + } + } + } +} + +#[cfg(test)] +#[path = "hyperlinks_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/textarea/hyperlinks_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/hyperlinks_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e9b8c67ba8569cd9acc6ad68d84c04c5707bcb9b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/hyperlinks_tests.rs @@ -0,0 +1,354 @@ +use super::super::TextArea; +use super::super::TextAreaState; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ChatComposer; +use crate::render::renderable::Renderable; +use crate::terminal_hyperlinks::strip_osc8; +use crate::width::display_width; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use pretty_assertions::assert_eq; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::widgets::StatefulWidgetRef; +use tokio::sync::mpsc::unbounded_channel; + +type LinkedCell = (u16, u16, String, String); + +fn linked_cells(buf: &Buffer) -> Vec { + buf.area + .positions() + .filter_map(|position| { + let symbol = buf[position].symbol(); + let (destination, _) = symbol.strip_prefix("\x1b]8;;")?.split_once('\x07')?; + Some(( + position.x, + position.y, + strip_osc8(symbol), + destination.to_string(), + )) + }) + .collect() +} + +fn render(text: &str, width: u16, height: u16) -> (TextArea, TextAreaState, Buffer) { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buf = Buffer::empty(area); + let mut state = TextAreaState::default(); + StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state); + (textarea, state, buf) +} + +fn linked_text(cells: &[LinkedCell]) -> String { + cells.iter().map(|(_, _, text, _)| text.as_str()).collect() +} + +fn hyperlink_snapshot(buf: &Buffer) -> String { + let mut snapshot = (0..buf.area.height) + .map(|row| { + let text = (0..buf.area.width) + .map(|column| strip_osc8(buf[(column, row)].symbol())) + .collect::(); + format!("|{text}|\n") + }) + .collect::(); + let linked = linked_cells(buf); + snapshot.push_str("Hyperlinks:\n"); + if linked.is_empty() { + snapshot.push_str(" none\n"); + } + for cells in linked.chunk_by(|left, right| { + left.1 == right.1 + && left.3 == right.3 + && usize::from(left.0) + display_width(&left.2) == usize::from(right.0) + }) { + let (column, row, _, destination) = &cells[0]; + let last = cells.last().unwrap(); + let end = usize::from(last.0) + display_width(&last.2); + snapshot.push_str(&format!( + " row {row}, columns {column}..{end} -> {destination}\n" + )); + } + snapshot +} + +fn assert_destination(buf: &Buffer, destination: &str) -> Vec { + let linked = linked_cells(buf); + assert!(!linked.is_empty()); + assert!(linked.iter().all(|(_, _, _, target)| target == destination)); + linked +} + +#[test] +fn wrapped_url_fragments_keep_the_complete_destination() { + let url = "https://github.com/openai/codex/pull/20252"; + let (_, _, buf) = render( + &format!("Fix CI on {url}"), + /*width*/ 26, + /*height*/ 5, + ); + let area = buf.area; + let linked = assert_destination(&buf, url); + assert_eq!(linked_text(&linked), url); + assert!(linked.windows(2).any(|pair| pair[0].1 != pair[1].1)); + assert!(!buf[(0, 0)].symbol().contains("\x1b]8;;")); + + let visible = (0..area.height) + .map(|row| { + (0..area.width) + .map(|column| strip_osc8(buf[(column, row)].symbol())) + .collect::() + .trim_end() + .to_string() + }) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n"); + insta::assert_snapshot!("wrapped_textarea_url_keeps_original_appearance", visible); +} + +#[test] +fn composer_wrapped_url_fragments_keep_the_complete_destination() { + let url = "https://github.com/openai/codex/pull/20252"; + let (sender, _receiver) = unbounded_channel::(); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(sender), + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.set_text_content(format!("Fix CI on {url}"), Vec::new(), Vec::new()); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 34, /*height*/ 8, + ); + let mut buf = Buffer::empty(area); + + composer.render(area, &mut buf); + + let linked = assert_destination(&buf, url); + assert_eq!(linked_text(&linked), url); + assert!(linked.windows(2).any(|pair| pair[0].1 != pair[1].1)); +} + +#[test] +fn scrolled_url_fragments_keep_the_offscreen_destination() { + let url = "https://example.test/alpha/beta/gamma/delta/epsilon"; + let (_, state, buf) = render(url, /*width*/ 16, /*height*/ 2); + let visible_url = linked_text(&assert_destination(&buf, url)); + assert!(state.scroll > 0); + assert_ne!(visible_url, url); + assert!(url.ends_with(&visible_url)); +} + +#[test] +fn long_drafts_reuse_hyperlink_detection_across_cursor_redraws() { + let text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); + let (mut textarea, mut state, mut buf) = render(&text, /*width*/ 80, /*height*/ 2); + let area = buf.area; + + let first_cache = { + let cache = textarea.wrap_cache.borrow(); + let hyperlinks = cache.as_ref().unwrap().hyperlinks.get().unwrap(); + assert!(hyperlinks.hyperlinks.is_empty()); + hyperlinks as *const super::HyperlinkCache + }; + + textarea.set_cursor(text.len() - 1); + StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state); + + let cache = textarea.wrap_cache.borrow(); + let second_cache = cache.as_ref().unwrap().hyperlinks.get().unwrap(); + assert!(std::ptr::eq(first_cache, second_cache)); +} + +#[test] +fn maximum_length_urls_render_without_osc8_annotations() { + let prefix = "https://example.test/"; + let text = format!( + "{prefix}{}", + "a".repeat(MAX_USER_INPUT_TEXT_CHARS - prefix.len()) + ); + let (textarea, _, buf) = render(&text, /*width*/ 80, /*height*/ 2); + + assert_eq!(textarea.text(), text); + assert_eq!(linked_cells(&buf), Vec::new()); + assert!(buf.content.iter().all(|cell| cell.symbol().len() == 1)); + insta::assert_snapshot!( + "maximum_length_url_without_annotations", + hyperlink_snapshot(&buf) + ); +} + +#[test] +fn many_urls_render_with_the_complete_destination() { + let destination = "https://x.io"; + let text = format!("{destination} ").repeat(/*n*/ 50_000); + let (textarea, state, buf) = render(&text, /*width*/ 80, /*height*/ 2); + + assert!(state.scroll > 0); + assert_destination(&buf, destination); + assert_eq!( + textarea + .wrap_cache + .borrow() + .as_ref() + .unwrap() + .hyperlinks + .get() + .unwrap() + .hyperlinks + .len(), + 50_000 + ); +} + +#[test] +fn joined_emoji_preserve_complete_url_cell_ranges() { + let mut snapshots = Vec::new(); + for url in ["https://example.test/path", "https://example.test/👩‍💻/path"] { + for width in [64, 24, 16] { + let (_, _, buf) = render(&format!("👩‍💻 {url} tail"), width, /*height*/ 6); + let linked = assert_destination(&buf, url); + assert_eq!(linked_text(&linked), url); + for row in 0..buf.area.height { + let cells = linked + .iter() + .filter(|(_, y, _, _)| *y == row) + .cloned() + .collect::>(); + if let (Some(first), Some(last)) = (cells.first(), cells.last()) { + snapshots.push(format!( + "width {width}, row {row}, columns {}..={}: {}", + first.0, + last.0, + linked_text(&cells) + )); + } + } + } + } + insta::assert_snapshot!("joined_emoji_url_cell_ranges", snapshots.join("\n")); +} + +#[test] +fn unicode_whitespace_separates_url_destinations() { + let first = "https://one.test/a"; + let second = "https://two.test/b"; + let mut snapshots = Vec::new(); + for separator in ['\u{2003}', '\u{a0}', '\u{2007}', '\u{202f}', '\u{2028}'] { + for width in [48, 20] { + let (_, _, buf) = render( + &format!("{first}{separator}{second}"), + width, + /*height*/ 4, + ); + let linked = linked_cells(&buf); + for destination in [first, second] { + let cells = linked + .iter() + .filter(|(_, _, _, target)| target == destination) + .cloned() + .collect::>(); + assert_eq!(linked_text(&cells), destination); + } + assert_eq!(linked.len(), first.len() + second.len()); + snapshots.push(format!( + "separator U+{:04X}, width {width}\n{}", + separator as u32, + hyperlink_snapshot(&buf) + )); + } + } + insta::assert_snapshot!("unicode_whitespace_url_destinations", snapshots.join("\n")); +} + +#[test] +fn hyperlink_cache_is_invalidated_when_text_changes() { + let first = "https://one.test/before"; + let second = "https://two.test/after"; + let (mut textarea, mut state, mut buf) = render(first, /*width*/ 40, /*height*/ 1); + let area = buf.area; + + textarea.set_text_clearing_elements(second); + assert!(textarea.wrap_cache.borrow().is_none()); + + buf = Buffer::empty(area); + StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state); + assert_eq!(linked_text(&assert_destination(&buf, second)), second); +} + +#[test] +fn distinct_urls_respect_punctuation_wide_prefixes_and_tabs() { + let first = "https://one.test/a"; + let second = "https://two.test/b"; + let (_, _, buf) = render( + &format!("界\t({first}),\n{second}!"), + /*width*/ 48, + /*height*/ 2, + ); + let linked = linked_cells(&buf); + for (row, column, destination) in [(0, 4, first), (1, 0, second)] { + let cells = linked + .iter() + .filter(|(_, cell_row, _, _)| *cell_row == row) + .cloned() + .collect::>(); + assert_eq!(cells.first().map(|cell| cell.0), Some(column)); + assert!(cells.iter().all(|cell| cell.3 == destination)); + assert_eq!(linked_text(&cells), destination); + } +} + +#[test] +fn url_hyperlinks_preserve_existing_highlight_styles() { + let url = "https://example.test/highlight"; + let mut textarea = TextArea::new(); + textarea.insert_str(url); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 1, + ); + let mut buf = Buffer::empty(area); + let mut state = TextAreaState::default(); + let highlight = Style::default().fg(Color::Magenta); + + textarea.render_ref_styled_with_highlights( + area, + &mut buf, + &mut state, + Style::default(), + &[(0..url.len(), highlight)], + ); + + let linked = assert_destination(&buf, url); + assert_eq!(linked.len(), url.len()); + assert!( + linked + .iter() + .all(|(column, row, _, _)| { buf[(*column, *row)].fg == Color::Magenta }) + ); +} + +#[test] +fn masked_url_input_never_exposes_hyperlink_destinations() { + let url = "https://example.test/secret"; + let mut textarea = TextArea::new(); + textarea.insert_str(url); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 1, + ); + let mut buf = Buffer::empty(area); + let mut state = TextAreaState::default(); + + textarea.render_ref_masked(area, &mut buf, &mut state, '*'); + + assert_eq!(linked_cells(&buf), Vec::new()); + assert!( + area.positions() + .all(|position| !buf[position].symbol().contains(url)) + ); +} diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__joined_emoji_url_cell_ranges.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__joined_emoji_url_cell_ranges.snap new file mode 100644 index 0000000000000000000000000000000000000000..426ed7d24607c9fef29f9c981afef7478505a5fc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__joined_emoji_url_cell_ranges.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs +expression: "snapshots.join(\"\\n\")" +--- +width 64, row 0, columns 3..=27: https://example.test/path +width 24, row 0, columns 3..=23: https://example.test/ +width 24, row 1, columns 0..=3: path +width 16, row 0, columns 3..=10: https:// +width 16, row 1, columns 0..=12: example.test/ +width 16, row 2, columns 0..=3: path +width 64, row 0, columns 3..=30: https://example.test/👩‍💻/path +width 24, row 0, columns 3..=23: https://example.test/ +width 24, row 1, columns 0..=6: 👩‍💻/path +width 16, row 0, columns 3..=10: https:// +width 16, row 1, columns 0..=15: example.test/👩‍💻/ +width 16, row 2, columns 0..=3: path diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__maximum_length_url_without_annotations.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__maximum_length_url_without_annotations.snap new file mode 100644 index 0000000000000000000000000000000000000000..e9237679bb2ec415c5179a4150e40b5f29c95c3f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__maximum_length_url_without_annotations.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs +expression: hyperlink_snapshot(&buf) +--- +|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa| +|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | +Hyperlinks: + none diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__unicode_whitespace_url_destinations.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__unicode_whitespace_url_destinations.snap new file mode 100644 index 0000000000000000000000000000000000000000..7f3b2c52616991dc68e39938ff1d83c220b97acc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__unicode_whitespace_url_destinations.snap @@ -0,0 +1,96 @@ +--- +source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs +expression: "snapshots.join(\"\\n\")" +--- +separator U+2003, width 48 +|https://one.test/a https://two.test/b | +| | +| | +| | +Hyperlinks: + row 0, columns 0..18 -> https://one.test/a + row 0, columns 19..37 -> https://two.test/b + +separator U+2003, width 20 +|https://one.test/a  | +|https://two.test/b | +| | +| | +Hyperlinks: + row 0, columns 0..18 -> https://one.test/a + row 1, columns 0..18 -> https://two.test/b + +separator U+00A0, width 48 +|https://one.test/a https://two.test/b | +| | +| | +| | +Hyperlinks: + row 0, columns 0..18 -> https://one.test/a + row 0, columns 19..37 -> https://two.test/b + +separator U+00A0, width 20 +|https://one.test/ | +|a https://two.test/b| +| | +| | +Hyperlinks: + row 0, columns 0..17 -> https://one.test/a + row 1, columns 0..1 -> https://one.test/a + row 1, columns 2..20 -> https://two.test/b + +separator U+2007, width 48 +|https://one.test/a https://two.test/b | +| | +| | +| | +Hyperlinks: + row 0, columns 0..18 -> https://one.test/a + row 0, columns 19..37 -> https://two.test/b + +separator U+2007, width 20 +|https://one.test/ | +|a https://two.test/b| +| | +| | +Hyperlinks: + row 0, columns 0..17 -> https://one.test/a + row 1, columns 0..1 -> https://one.test/a + row 1, columns 2..20 -> https://two.test/b + +separator U+202F, width 48 +|https://one.test/a https://two.test/b | +| | +| | +| | +Hyperlinks: + row 0, columns 0..18 -> https://one.test/a + row 0, columns 19..37 -> https://two.test/b + +separator U+202F, width 20 +|https://one.test/ | +|a https://two.test/b| +| | +| | +Hyperlinks: + row 0, columns 0..17 -> https://one.test/a + row 1, columns 0..1 -> https://one.test/a + row 1, columns 2..20 -> https://two.test/b + +separator U+2028, width 48 +|https://one.test/a
https://two.test/b | +| | +| | +| | +Hyperlinks: + row 0, columns 0..18 -> https://one.test/a + row 0, columns 19..37 -> https://two.test/b + +separator U+2028, width 20 +|https://one.test/a
 | +|https://two.test/b | +| | +| | +Hyperlinks: + row 0, columns 0..18 -> https://one.test/a + row 1, columns 0..18 -> https://two.test/b diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__wrapped_textarea_url_keeps_original_appearance.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__wrapped_textarea_url_keeps_original_appearance.snap new file mode 100644 index 0000000000000000000000000000000000000000..ab2fd301165713530b94bc6a57dc21b0ce50787e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__hyperlinks__tests__wrapped_textarea_url_keeps_original_appearance.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs +expression: visible +--- +Fix CI on https:// +github.com/openai/codex/ +pull/20252 diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__hanging_tab_cursor_and_scroll.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__hanging_tab_cursor_and_scroll.snap new file mode 100644 index 0000000000000000000000000000000000000000..9dc7bde124c0124defcce9bf0aa502a4ae50e9c0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__hanging_tab_cursor_and_scroll.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/textarea/wrapping_tests.rs +expression: "format!(\"cursors: {cursors:?}\\nscroll: {}\\ncursor: {scrolled_cursor:?}\\n{}\",\nstate.scroll, terminal.backend())" +--- +cursors: [Some((0, 0)), Some((1, 0)), Some((2, 0)), Some((3, 0)), Some((0, 1)), Some((0, 1)), Some((1, 1))] +scroll: 1 +cursor: Some((0, 0)) +"a " diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__mandatory_break_cursor.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__mandatory_break_cursor.snap new file mode 100644 index 0000000000000000000000000000000000000000..81509b0aabd4c10367f52bd07441af892c8d2496 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__mandatory_break_cursor.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/textarea/wrapping_tests.rs +expression: "format!(\"before: {before:?}\\nafter: {after:?}\\n{}\",\nterminal.backend()).replace(separator, \"\\\\u{2028}\")" +--- +before: Some((0, 1)) +after: (7, Some((1, 1))) +"abad" +"\u{2028}nex" +"t " diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__typing_after_eol_spaces.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__typing_after_eol_spaces.snap new file mode 100644 index 0000000000000000000000000000000000000000..f3522c6b9363b6d5592cc72c2bf641f6cc417020 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__typing_after_eol_spaces.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/bottom_pane/textarea/wrapping_tests.rs +expression: "snapshots.join(\"\\n\\n\")" +--- +full line +text: "abad" +cursor: (0, 1) +"abad" +" " + + +trailing spaces +text: "abad " +cursor: (3, 1) +"abad" +" " + + +following word +text: "abad x" +cursor: (1, 1) +"abad" +"x " + + +delete following word +text: "abad " +cursor: (3, 1) +"abad" +" " diff --git a/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__vertical_navigation_after_resize.snap b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__vertical_navigation_after_resize.snap new file mode 100644 index 0000000000000000000000000000000000000000..357c0ee2c72c104c39e835cc77e883f56dfbc6e6 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/snapshots/codex_tui__bottom_pane__textarea__wrapping__tests__vertical_navigation_after_resize.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/bottom_pane/textarea/wrapping_tests.rs +expression: "format!(\"{}\\n\\n{}\", cursors.join(\"\\n\"), layouts.join(\"\\n\"))" +--- +up at width 10: (10, Some((0, 1))) +resize to width 4: (10, Some((2, 2))) +down to abcd: (14, Some((3, 3))) +down to xyz: (20, Some((3, 4))) +up to abcd: (14, Some((3, 3))) +up to ij: (10, Some((2, 2))) +restore width 10: (20, Some((9, 2))) + +width 4 +"abcd" +"efgh" +"ij " +"abcd" +"xyz " + +width 10 +"abcdefghij" +" " +"abcd xyz " diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs new file mode 100644 index 0000000000000000000000000000000000000000..afd47e7d5d89b8e38aaa5cbc681a7857ea5571e4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -0,0 +1,336 @@ +use super::TextArea; +use super::split_word_pieces; +use crate::key_hint::KeyBindingListExt; +use crossterm::event::KeyEvent; +use std::ops::Range; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimMode { + /// Normal mode routes printable keys to movement, operators, and mode transitions. + Normal, + /// Insert mode routes input through the regular editor keymap until Escape is pressed. + Insert, + /// Replace mode overwrites existing graphemes through the regular editor keymap. + Replace, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimOperator { + Delete, + Yank, + Change, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimPending { + None, + Operator(VimOperator), + TextObject { + operator: VimOperator, + scope: VimTextObjectScope, + }, + Replace, + Find { + motion: VimFindMotion, + operator: Option, + }, +} + +/// Line-local character motions shared by navigation, operators, and dot replay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimFindMotion { + Forward, + Backward, + TillForward, + TillBackward, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimMotion { + Left, + Right, + Up, + Down, + WordForward, + WordBackward, + WordEnd, + LineStart, + LineEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimTextObjectScope { + Inner, + Around, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimTextObject { + Word, + BigWord, + Parentheses, + Brackets, + Braces, + DoubleQuote, + SingleQuote, + Backtick, +} + +impl TextArea { + pub(super) fn vim_text_object_scope_for_event( + &self, + event: KeyEvent, + ) -> Option { + if self + .vim_operator_keymap + .select_inner_text_object + .is_pressed(event) + { + return Some(VimTextObjectScope::Inner); + } + if self + .vim_operator_keymap + .select_around_text_object + .is_pressed(event) + { + return Some(VimTextObjectScope::Around); + } + None + } + + pub(super) fn vim_text_object_for_event(&self, event: KeyEvent) -> Option { + if self.vim_text_object_keymap.word.is_pressed(event) { + return Some(VimTextObject::Word); + } + if self.vim_text_object_keymap.big_word.is_pressed(event) { + return Some(VimTextObject::BigWord); + } + if self.vim_text_object_keymap.parentheses.is_pressed(event) { + return Some(VimTextObject::Parentheses); + } + if self.vim_text_object_keymap.brackets.is_pressed(event) { + return Some(VimTextObject::Brackets); + } + if self.vim_text_object_keymap.braces.is_pressed(event) { + return Some(VimTextObject::Braces); + } + if self.vim_text_object_keymap.double_quote.is_pressed(event) { + return Some(VimTextObject::DoubleQuote); + } + if self.vim_text_object_keymap.single_quote.is_pressed(event) { + return Some(VimTextObject::SingleQuote); + } + if self.vim_text_object_keymap.backtick.is_pressed(event) { + return Some(VimTextObject::Backtick); + } + None + } + + pub(super) fn text_object_range( + &self, + object: VimTextObject, + scope: VimTextObjectScope, + ) -> Option> { + match object { + VimTextObject::Word => self.word_text_object_range(scope, /*big_word*/ false), + VimTextObject::BigWord => self.word_text_object_range(scope, /*big_word*/ true), + VimTextObject::Parentheses => self.paired_text_object_range(scope, '(', ')'), + VimTextObject::Brackets => self.paired_text_object_range(scope, '[', ']'), + VimTextObject::Braces => self.paired_text_object_range(scope, '{', '}'), + VimTextObject::DoubleQuote => self.quoted_text_object_range(scope, '"'), + VimTextObject::SingleQuote => self.quoted_text_object_range(scope, '\''), + VimTextObject::Backtick => self.quoted_text_object_range(scope, '`'), + } + } + + fn word_text_object_range( + &self, + scope: VimTextObjectScope, + big_word: bool, + ) -> Option> { + let inner = if big_word { + self.big_word_range_at_cursor()? + } else { + self.small_word_range_at_cursor()? + }; + Some(match scope { + VimTextObjectScope::Inner => inner, + VimTextObjectScope::Around => self.expand_word_around(inner), + }) + } + + fn big_word_range_at_cursor(&self) -> Option> { + self.non_ws_runs() + .into_iter() + .find(|range| self.cursor_overlaps_range(range) || self.cursor_is_at_range_end(range)) + } + + fn small_word_range_at_cursor(&self) -> Option> { + for run in self.non_ws_runs() { + if !self.cursor_overlaps_range(&run) && !self.cursor_is_at_range_end(&run) { + continue; + } + let mut last_piece = None; + for (piece_start, piece) in split_word_pieces(&self.text[run.clone()]) { + let piece = run.start + piece_start..run.start + piece_start + piece.len(); + if self.cursor_overlaps_range(&piece) { + return Some(piece); + } + last_piece = Some(piece); + } + if self.cursor_is_at_range_end(&run) { + return last_piece.or(Some(run)); + } + return Some(run); + } + None + } + + fn non_ws_runs(&self) -> Vec> { + let mut runs = Vec::new(); + let mut start = None; + for (idx, ch) in self.text.char_indices() { + if ch.is_whitespace() { + if let Some(run_start) = start.take() { + runs.push(run_start..idx); + } + } else if start.is_none() { + start = Some(idx); + } + } + if let Some(run_start) = start { + runs.push(run_start..self.text.len()); + } + runs + } + + fn cursor_overlaps_range(&self, range: &Range) -> bool { + range.start <= self.cursor_pos && self.cursor_pos < range.end + } + + fn cursor_is_at_range_end(&self, range: &Range) -> bool { + range.start < range.end && self.cursor_pos == range.end + } + + fn expand_word_around(&self, inner: Range) -> Range { + let following = self.following_whitespace_end(inner.end); + if following > inner.end { + return inner.start..following; + } + self.preceding_whitespace_start(inner.start)..inner.end + } + + fn following_whitespace_end(&self, start: usize) -> usize { + let mut end = start; + for (offset, ch) in self.text[start..].char_indices() { + if !ch.is_whitespace() { + break; + } + end = start + offset + ch.len_utf8(); + } + end + } + + fn preceding_whitespace_start(&self, end: usize) -> usize { + let mut start = end; + for (idx, ch) in self.text[..end].char_indices().rev() { + if !ch.is_whitespace() { + break; + } + start = idx; + } + start + } + + fn paired_text_object_range( + &self, + scope: VimTextObjectScope, + open: char, + close: char, + ) -> Option> { + let mut stack: Vec = Vec::new(); + let mut best: Option> = None; + for (idx, ch) in self.text.char_indices() { + if self.is_inside_element(idx) { + continue; + } + if ch == open { + stack.push(idx); + } else if ch == close { + let Some(open_idx) = stack.pop() else { + continue; + }; + let close_end = idx + ch.len_utf8(); + if open_idx <= self.cursor_pos && self.cursor_pos <= idx { + let candidate = match scope { + VimTextObjectScope::Inner => open_idx + open.len_utf8()..idx, + VimTextObjectScope::Around => open_idx..close_end, + }; + if candidate.start <= candidate.end + && best + .as_ref() + .is_none_or(|current| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } + } + best + } + + fn quoted_text_object_range( + &self, + scope: VimTextObjectScope, + quote: char, + ) -> Option> { + let line = self.beginning_of_current_line()..self.end_of_current_line(); + let mut open = None; + let mut best: Option> = None; + for (offset, ch) in self.text[line.clone()].char_indices() { + let idx = line.start + offset; + if self.is_inside_element(idx) || ch != quote || self.is_escaped(idx) { + continue; + } + if let Some(open_idx) = open.take() { + if open_idx <= self.cursor_pos && self.cursor_pos <= idx { + let candidate = match scope { + VimTextObjectScope::Inner => open_idx + quote.len_utf8()..idx, + VimTextObjectScope::Around => idx_range(open_idx, idx, quote), + }; + if candidate.start <= candidate.end + && best + .as_ref() + .is_none_or(|current| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } else { + open = Some(idx); + } + } + best + } + + fn is_inside_element(&self, pos: usize) -> bool { + self.elements + .iter() + .any(|element| pos >= element.range.start && pos < element.range.end) + } + + fn is_escaped(&self, pos: usize) -> bool { + let mut backslashes = 0; + for ch in self.text[..pos].chars().rev() { + if ch != '\\' { + break; + } + backslashes += 1; + } + backslashes % 2 == 1 + } +} + +fn idx_range(open_idx: usize, close_idx: usize, quote: char) -> Range { + open_idx..close_idx + quote.len_utf8() +} diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs new file mode 100644 index 0000000000000000000000000000000000000000..6502b8a26d43e39028272fadb285e9228e4cbb31 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands.rs @@ -0,0 +1,701 @@ +//! Semantic Vim editing transactions, character find/till motions, and complete-change replay. +//! +//! `f`/`F` land on a matching grapheme; `t`/`T` stop just before/after it. Forward operator +//! motions include the destination, while backward motions exclude the original cursor. +//! All four motions share these boundaries for navigation, `c`/`d`/`y`, and semantic `.` replay. + +use super::TextArea; +use super::VimMode; +use super::VimMotion; +use super::VimOperator; +use super::VimPending; +use super::VimTextObject; +use super::VimTextObjectScope; +use super::vim::VimFindMotion; +use crate::key_hint::KeyBindingListExt; +use crate::vim_search::SearchQuery; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use unicode_segmentation::GraphemeCursor; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone, Debug)] +pub(crate) enum VimEdit { + Editor(VimEditorEdit), + Text(String), +} + +/// Vim command recording and searches preserved across same-draft restoration. +#[derive(Debug, Default)] +pub(crate) struct VimPersistentState { + pub(crate) commands: VimCommandState, + search: crate::vim_search::SearchQuery, +} + +#[derive(Clone, Debug)] +pub(crate) struct VimEditorEdit(VimAction); + +#[derive(Clone, Copy, Debug)] +pub(super) enum VimInsertPosition { + Cursor, + AfterCursor, + LineStart, + LineEnd, + OpenAbove, + OpenBelow, +} + +#[derive(Clone, Debug)] +pub(super) enum VimEditTarget { + Character, + Line, + LineEnd, + Motion(VimMotion), + Search(SearchQuery), + TextObject { + scope: VimTextObjectScope, + object: VimTextObject, + }, + Find { + motion: VimFindMotion, + target: char, + }, + BufferJump { + last: bool, + }, +} + +#[derive(Clone, Debug)] +pub(super) enum VimAction { + Insert(VimInsertPosition), + EnterReplaceMode, + RestoreReplacedCharacter, + Delete(VimEditTarget), + Change(VimEditTarget), + Replace(char), + PasteAfter, + DeleteBackward, + DeleteForward, + DeleteBackwardWord, + DeleteForwardWord, + KillLineStart, + KillLine, + KillLineEnd, + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveWordLeft, + MoveWordRight, + MoveLineStart { move_up_at_bol: bool }, + MoveLineEnd { move_down_at_eol: bool }, +} + +#[derive(Debug, Default)] +pub(crate) struct VimCommandState { + pub(super) pending_change: Vec, + pub(crate) last_change: Vec, + changed: bool, + pub(super) replaying: bool, + replace_steps: Vec, +} + +#[derive(Debug)] +struct VimReplaceStep { + // Backspace also retraces attachments skipped before the replacement. + cursor_before: usize, + start: usize, + inserted_len: usize, + original: String, +} + +impl TextArea { + pub(crate) fn is_vim_replace_mode(&self) -> bool { + self.vim_enabled && self.vim_mode == VimMode::Replace + } + + pub(super) fn clear_vim_replace_recovery(&mut self) { + self.vim_commands.replace_steps.clear(); + } + + pub(super) fn replace_vim_text(&mut self, text: &str) { + for grapheme in text.graphemes(/*is_extended*/ true) { + let cursor_before = self.cursor_pos; + while let Some(element) = self + .elements + .iter() + .find(|element| element.range.start == self.cursor_pos) + { + self.set_cursor(element.range.end); + } + let start = self.cursor_pos; + let end = if grapheme == "\n" || start >= self.end_of_current_line() { + start + } else { + self.next_atomic_boundary(start) + }; + let original = self.text[start..end].to_string(); + self.replace_range_preserving_recovery(start..end, grapheme); + self.vim_commands.replace_steps.push(VimReplaceStep { + cursor_before, + start, + inserted_len: grapheme.len(), + original, + }); + } + } + + pub(super) fn restore_vim_replaced_character(&mut self) -> bool { + let steps = &mut self.vim_commands.replace_steps; + let Some(step) = steps + .pop() + .filter(|step| self.cursor_pos == step.start + step.inserted_len) + else { + steps.clear(); + return false; + }; + // Replace skips existing elements, so an overlapping marker was added after typing. + // Unmark it before restoring one character; never expand recovery to the whole token. + self.elements.retain(|element| { + element.range.end <= step.start || element.range.start >= step.start + step.inserted_len + }); + self.replace_range_preserving_recovery( + step.start..step.start + step.inserted_len, + &step.original, + ); + self.set_cursor(step.cursor_before); + true + } + + /// Retract a detected paste prefix without losing overwritten text or crossing attachments. + pub(crate) fn retract_paste_burst(&mut self, start: usize) -> bool { + if self.is_vim_replace_mode() { + let restored_start = self + .vim_commands + .replace_steps + .iter() + .rev() + .take_while(|step| step.start >= start) + .try_fold(self.cursor_pos, |cursor, step| { + (step.start + step.inserted_len == cursor && step.cursor_before == step.start) + .then_some(step.start) + }); + if restored_start != Some(start) { + return false; + } + while self.cursor_pos > start { + self.apply_vim_insert_action(VimAction::RestoreReplacedCharacter); + } + } else { + self.replace_range(start..self.cursor_pos, ""); + } + true + } + + pub(crate) fn swap_vim_persistent_state(&mut self, state: &mut VimPersistentState) { + std::mem::swap(&mut self.vim_commands, &mut state.commands); + std::mem::swap(&mut self.vim_search.last, &mut state.search); + } + + pub(crate) fn vim_repeat_actions(&self) -> Option> { + (!self.vim_commands.last_change.is_empty()).then(|| self.vim_commands.last_change.clone()) + } + + pub(super) fn record_vim_inserted_text(&mut self, text: &str) { + if !self.vim_enabled + || !matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) + || self.vim_commands.replaying + || self.vim_commands.pending_change.is_empty() + || text.is_empty() + { + return; + } + if self.vim_mode == VimMode::Insert + && let Some(VimEdit::Text(pending)) = self.vim_commands.pending_change.last_mut() + { + pending.push_str(text); + } else { + self.vim_commands + .pending_change + .push(VimEdit::Text(text.to_owned())); + } + self.vim_commands.changed = true; + } + + pub(super) fn apply_vim_insert_action(&mut self, action: VimAction) -> bool { + let recording = self.vim_enabled + && matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) + && !self.vim_commands.replaying + && !self.vim_commands.pending_change.is_empty(); + let prior_len = self.text.len(); + if !self.apply_vim_editor_action(action.clone()) { + return false; + } + let changed = + self.text.len() != prior_len || matches!(action, VimAction::RestoreReplacedCharacter); + let deletion = matches!( + action, + VimAction::DeleteBackward + | VimAction::DeleteForward + | VimAction::DeleteBackwardWord + | VimAction::DeleteForwardWord + | VimAction::KillLineStart + | VimAction::KillLine + | VimAction::KillLineEnd + ); + if recording && (changed || !deletion) { + self.vim_commands + .pending_change + .push(VimEdit::Editor(VimEditorEdit(action))); + } + self.vim_commands.changed |= recording && changed; + true + } + + pub(super) fn start_vim_edit(&mut self, action: VimAction) -> bool { + let prior_len = self.text.len(); + self.vim_commands.pending_change = vec![VimEdit::Editor(VimEditorEdit(action.clone()))]; + self.vim_commands.changed = false; + if !self.apply_vim_editor_action(action.clone()) { + self.vim_commands.pending_change.clear(); + return false; + } + self.vim_commands.changed = + self.text.len() != prior_len || matches!(action, VimAction::Replace(_)); + if self.vim_mode == VimMode::Normal { + self.finish_pending_vim_change(); + } + true + } + + pub(super) fn finish_pending_vim_change(&mut self) { + if self.vim_commands.changed { + self.vim_commands.last_change = std::mem::take(&mut self.vim_commands.pending_change); + } else { + self.vim_commands.pending_change.clear(); + } + self.vim_commands.changed = false; + } + + pub(crate) fn begin_vim_repeat(&mut self) -> Option> { + let edits = self.vim_repeat_actions()?; + self.vim_commands.replaying = true; + Some(edits) + } + + pub(crate) fn finish_vim_repeat(&mut self) { + if matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) { + self.leave_vim_insert_mode(); + } + self.vim_pending = VimPending::None; + self.vim_commands.replaying = false; + } + + pub(crate) fn apply_vim_edit(&mut self, edit: &VimEdit) -> bool { + match edit { + VimEdit::Editor(VimEditorEdit(action)) => self.apply_vim_editor_action(action.clone()), + VimEdit::Text(text) => { + if !matches!(self.vim_mode, VimMode::Insert | VimMode::Replace) { + return false; + } + self.insert_str(text); + true + } + } + } + + fn apply_vim_editor_action(&mut self, action: VimAction) -> bool { + // Editor actions invalidate contiguous Replace offsets, including during replay. + if !matches!(action, VimAction::RestoreReplacedCharacter) { + self.clear_vim_replace_recovery(); + } + let prior_len = self.text.len(); + let is_change = matches!(action, VimAction::Change(_)); + match action { + VimAction::EnterReplaceMode => { + self.vim_mode = VimMode::Replace; + } + VimAction::RestoreReplacedCharacter => { + return self.restore_vim_replaced_character(); + } + VimAction::Insert(position) => { + match position { + VimInsertPosition::Cursor => {} + VimInsertPosition::AfterCursor => { + self.set_cursor(self.next_atomic_boundary(self.cursor_pos)); + } + VimInsertPosition::LineStart => { + self.set_cursor(self.first_non_blank_of_current_line()); + } + VimInsertPosition::LineEnd => self.set_cursor(self.end_of_current_line()), + VimInsertPosition::OpenAbove => { + let bol = self.beginning_of_current_line(); + self.insert_str_at(bol, "\n"); + self.set_cursor(bol); + } + VimInsertPosition::OpenBelow => { + let eol = self.end_of_current_line(); + let insert_at = if eol < prior_len { eol + 1 } else { eol }; + self.insert_str_at(insert_at, "\n"); + self.set_cursor(if eol < prior_len { + insert_at + } else { + insert_at + 1 + }); + } + } + self.vim_mode = VimMode::Insert; + } + VimAction::Delete(target) | VimAction::Change(target) => { + let operator = if !is_change { + VimOperator::Delete + } else { + VimOperator::Change + }; + match target { + VimEditTarget::Character => { + if self.cursor_pos < self.end_of_current_line() { + self.delete_forward_kill(/*n*/ 1); + } + if operator == VimOperator::Change { + self.vim_mode = VimMode::Insert; + } + } + VimEditTarget::Line => { + if operator == VimOperator::Delete { + self.kill_current_line(); + } else { + let range = + self.beginning_of_current_line()..self.end_of_current_line(); + self.kill_line_range(range); + self.vim_mode = VimMode::Insert; + } + } + VimEditTarget::LineEnd => { + self.vim_kill_to_end_of_line(); + if operator == VimOperator::Change { + self.vim_mode = VimMode::Insert; + } + } + VimEditTarget::Motion(motion) => self.apply_vim_operator(operator, motion), + VimEditTarget::Search(query) => { + if !self.apply_vim_search(&query, Some(operator)) { + return false; + } + } + VimEditTarget::TextObject { scope, object } => { + let Some(range) = self.text_object_range(object, scope) else { + return false; + }; + self.apply_vim_operator_to_range(operator, range); + } + VimEditTarget::Find { motion, target } => { + if !self.find_vim_character(motion, Some(operator), target) { + return false; + } + } + VimEditTarget::BufferJump { last } => { + self.jump_to_vim_buffer_line(last, Some(operator)); + } + } + if operator == VimOperator::Change { + return self.vim_mode == VimMode::Insert; + } + return self.text.len() != prior_len; + } + VimAction::Replace(ch) => { + if self.cursor_pos >= self.end_of_current_line() { + return false; + } + let start = self.cursor_pos; + let end = self.next_atomic_boundary(start); + self.replace_range(start..end, &ch.to_string()); + self.set_cursor(start + usize::from(ch == '\n')); + } + VimAction::PasteAfter => { + self.paste_after_cursor(); + return self.text.len() != prior_len; + } + VimAction::DeleteBackward => self.delete_backward(/*n*/ 1), + VimAction::DeleteForward => self.delete_forward(/*n*/ 1), + VimAction::DeleteBackwardWord => self.delete_backward_word(), + VimAction::DeleteForwardWord => self.delete_forward_word(), + VimAction::KillLineStart => self.kill_to_beginning_of_line(), + VimAction::KillLine => self.kill_current_line(), + VimAction::KillLineEnd => self.kill_to_end_of_line(), + VimAction::MoveLeft => self.move_cursor_left(), + VimAction::MoveRight => self.move_cursor_right(), + VimAction::MoveUp => self.move_cursor_up(), + VimAction::MoveDown => self.move_cursor_down(), + VimAction::MoveWordLeft => self.set_cursor(self.beginning_of_previous_word()), + VimAction::MoveWordRight => self.set_cursor(self.end_of_next_word()), + VimAction::MoveLineStart { move_up_at_bol } => { + self.move_cursor_to_beginning_of_line(move_up_at_bol); + } + VimAction::MoveLineEnd { move_down_at_eol } => { + self.move_cursor_to_end_of_line(move_down_at_eol); + } + } + true + } + + pub(super) fn handle_vim_extra_command(&mut self, event: KeyEvent) -> bool { + if self.vim_normal_keymap.enter_replace_mode.is_pressed(event) { + self.start_vim_edit(VimAction::EnterReplaceMode); + return true; + } + + if self.vim_normal_keymap.replace_char.is_pressed(event) + && self.cursor_pos < self.end_of_current_line() + { + self.vim_pending = VimPending::Replace; + return true; + } + if self.vim_normal_keymap.repeat_last_change.is_pressed(event) { + if let Some(edits) = self.begin_vim_repeat() { + for edit in edits { + if !self.apply_vim_edit(&edit) { + break; + } + } + self.finish_vim_repeat(); + } + return true; + } + if self.vim_normal_keymap.find_forward.is_pressed(event) { + self.start_vim_find(VimFindMotion::Forward, /*operator*/ None); + } else if self.vim_normal_keymap.find_backward.is_pressed(event) { + self.start_vim_find(VimFindMotion::Backward, /*operator*/ None); + } else if self.vim_normal_keymap.till_forward.is_pressed(event) { + self.start_vim_find(VimFindMotion::TillForward, /*operator*/ None); + } else if self.vim_normal_keymap.till_backward.is_pressed(event) { + self.start_vim_find(VimFindMotion::TillBackward, /*operator*/ None); + } else if self.vim_normal_keymap.jump_top.is_pressed(event) { + self.jump_to_vim_buffer_line(/*last*/ false, /*operator*/ None); + } else if self.vim_normal_keymap.jump_bottom.is_pressed(event) { + self.jump_to_vim_buffer_line(/*last*/ true, /*operator*/ None); + } else { + return false; + } + true + } + + pub(super) fn handle_vim_operator_command( + &mut self, + operator: VimOperator, + event: KeyEvent, + ) -> bool { + if self + .vim_operator_keymap + .motion_find_forward + .is_pressed(event) + { + self.start_vim_find(VimFindMotion::Forward, Some(operator)); + } else if self + .vim_operator_keymap + .motion_find_backward + .is_pressed(event) + { + self.start_vim_find(VimFindMotion::Backward, Some(operator)); + } else if self + .vim_operator_keymap + .motion_till_forward + .is_pressed(event) + { + self.start_vim_find(VimFindMotion::TillForward, Some(operator)); + } else if self + .vim_operator_keymap + .motion_till_backward + .is_pressed(event) + { + self.start_vim_find(VimFindMotion::TillBackward, Some(operator)); + } else if self.vim_operator_keymap.motion_jump_top.is_pressed(event) + || self + .vim_operator_keymap + .motion_jump_bottom + .is_pressed(event) + { + let last = self + .vim_operator_keymap + .motion_jump_bottom + .is_pressed(event); + match operator { + VimOperator::Delete => { + self.start_vim_edit(VimAction::Delete(VimEditTarget::BufferJump { last })); + } + VimOperator::Change => { + self.start_vim_edit(VimAction::Change(VimEditTarget::BufferJump { last })); + } + VimOperator::Yank => self.jump_to_vim_buffer_line(last, Some(operator)), + } + } else { + return false; + } + true + } + + pub(super) fn handle_vim_pending_command(&mut self, pending: VimPending, event: KeyEvent) { + match pending { + VimPending::Replace => { + if let Some(ch) = vim_command_char(event) { + self.start_vim_edit(VimAction::Replace(ch)); + } + } + VimPending::Find { motion, operator } => { + if let Some(ch) = vim_command_char(event) { + match operator { + Some(VimOperator::Delete) => { + self.start_vim_edit(VimAction::Delete(VimEditTarget::Find { + motion, + target: ch, + })); + } + Some(VimOperator::Change) => { + self.start_vim_edit(VimAction::Change(VimEditTarget::Find { + motion, + target: ch, + })); + } + Some(VimOperator::Yank) | None => { + self.find_vim_character(motion, operator, ch); + } + } + } + } + VimPending::None | VimPending::Operator(_) | VimPending::TextObject { .. } => {} + } + } + + fn start_vim_find(&mut self, motion: VimFindMotion, operator: Option) { + self.vim_pending = VimPending::Find { motion, operator }; + } + + fn find_vim_character( + &mut self, + motion: VimFindMotion, + operator: Option, + target: char, + ) -> bool { + let origin = self.cursor_pos; + let found = match motion { + VimFindMotion::Forward | VimFindMotion::TillForward => { + let line_end = self.end_of_current_line(); + if origin >= line_end { + return false; + } + let start = self.next_atomic_boundary(origin); + self.text[start..line_end] + .grapheme_indices(/*is_extended*/ true) + .find(|(offset, grapheme)| { + grapheme.starts_with(target) && self.is_vim_command_target(start + offset) + }) + .map(|(offset, grapheme)| start + offset..start + offset + grapheme.len()) + } + VimFindMotion::Backward | VimFindMotion::TillBackward => { + let line_start = self.beginning_of_current_line(); + self.text[line_start..origin] + .grapheme_indices(/*is_extended*/ true) + .rev() + .find(|(offset, grapheme)| { + grapheme.starts_with(target) + && self.is_vim_command_target(line_start + offset) + }) + .map(|(offset, grapheme)| { + let start = line_start + offset; + start..start + grapheme.len() + }) + } + }; + let Some(position) = found else { + return false; + }; + if let Some(operator) = operator { + let range = match motion { + VimFindMotion::Forward => origin..position.end, + VimFindMotion::Backward => position.start..origin, + VimFindMotion::TillForward => origin..position.start, + VimFindMotion::TillBackward => position.end..origin, + }; + if operator == VimOperator::Yank { + self.set_cursor(range.start); + } + self.apply_vim_operator_to_range(operator, range); + } else { + let destination = match motion { + VimFindMotion::Forward | VimFindMotion::Backward => position.start, + VimFindMotion::TillForward => { + let previous = self.text[..position.start] + .grapheme_indices(/*is_extended*/ true) + .next_back() + .map_or(origin, |(offset, _)| offset); + self.find_element_containing(previous) + .map_or(previous, |idx| self.elements[idx].range.start) + } + VimFindMotion::TillBackward => position.end, + }; + self.set_cursor(destination); + } + true + } + + fn jump_to_vim_buffer_line(&mut self, last: bool, operator: Option) { + if let Some(operator) = operator { + let current = self.current_line_range_with_newline(); + let range = if last { + current.start..self.text.len() + } else { + 0..current.end + }; + match operator { + VimOperator::Delete => self.kill_line_range(range), + VimOperator::Yank => self.yank_line_range(range), + VimOperator::Change => { + self.kill_line_range(range); + self.vim_mode = VimMode::Insert; + } + } + return; + } + let start = if last { + self.beginning_of_line(self.text.len()) + } else { + 0 + }; + self.set_cursor(start); + self.set_cursor(self.first_non_blank_of_current_line()); + } + + pub(super) fn is_vim_command_target(&self, position: usize) -> bool { + !self + .elements + .iter() + .any(|element| element.range.contains(&position)) + && GraphemeCursor::new(position, self.text.len(), /*is_extended*/ true) + .is_boundary(&self.text, /*chunk_start*/ 0) + .unwrap_or(false) + } +} + +fn vim_command_char(event: KeyEvent) -> Option { + if event.code == KeyCode::Enter { + return Some('\n'); + } + let KeyCode::Char(ch) = event.code else { + return None; + }; + match event.modifiers { + KeyModifiers::NONE => Some(ch), + KeyModifiers::SHIFT => Some(if ch.is_ascii_lowercase() { + ch.to_ascii_uppercase() + } else { + ch + }), + modifiers if crate::key_hint::is_altgr(modifiers) => Some(ch), + _ => None, + } +} + +#[cfg(test)] +#[path = "vim_commands_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..a70afc7e024952e07e672c950c671ee44f3403a8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_commands_tests.rs @@ -0,0 +1,827 @@ +use super::super::TextArea; +use super::VimAction; +use crate::keymap::KeyChordMatch; +use crate::keymap::KeyChordMatcher; +use crate::keymap::RuntimeKeymap; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use tokio::time::Instant; + +fn vim_textarea(text: &str, cursor: usize) -> TextArea { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(cursor); + textarea.set_vim_enabled(/*enabled*/ true); + textarea.enable_vim_search(); + textarea +} + +fn keys(textarea: &mut TextArea, keys: &str) { + let keymap = RuntimeKeymap::defaults(); + let mut matcher = KeyChordMatcher::default(); + for key in keys.chars() { + let code = match key { + '\n' => KeyCode::Enter, + '\x1b' => KeyCode::Esc, + ch => KeyCode::Char(ch), + }; + let event = KeyEvent::new(code, KeyModifiers::NONE); + match matcher.advance( + event, + &keymap.chords, + textarea.keymap_contexts(), + Instant::now(), + ) { + KeyChordMatch::PassThrough => textarea.input(event), + KeyChordMatch::Completed(event) => textarea.input(event), + KeyChordMatch::Pending(_) | KeyChordMatch::Cancelled | KeyChordMatch::Ignored => {} + } + } +} + +fn escape(textarea: &mut TextArea) { + textarea.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); +} + +#[test] +fn replace_character_preserves_normal_mode_and_grapheme_boundaries() { + let mut textarea = vim_textarea("a👩‍💻c", /*cursor*/ 1); + keys(&mut textarea, "rZ"); + assert_eq!(textarea.text(), "aZc"); + assert_eq!(textarea.cursor(), 1); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); + + keys(&mut textarea, "r"); + assert_eq!( + textarea.keymap_context(), + crate::keymap::KeymapContext::Editor + ); + escape(&mut textarea); + assert_eq!(textarea.text(), "aZc"); + + keys(&mut textarea, "r\n"); + assert_eq!(textarea.text(), "a\nc"); + assert_eq!(textarea.cursor(), 2); + + #[cfg(windows)] + { + keys(&mut textarea, "r"); + textarea.input(KeyEvent::new( + KeyCode::Char('@'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )); + assert_eq!(textarea.text(), "a\n@"); + } +} + +#[test] +fn replace_mode_overwrites_graphemes_and_appends_at_line_end() { + let mut textarea = vim_textarea("a👩‍💻c\nnext", /*cursor*/ 1); + keys(&mut textarea, "RXY!"); + assert_eq!(textarea.text(), "aXY!\nnext"); + assert_eq!(textarea.vim_mode_label(), Some("Replace")); + assert_eq!( + textarea.keymap_context(), + crate::keymap::KeymapContext::Editor + ); + + escape(&mut textarea); + assert_eq!( + (textarea.vim_mode_label(), textarea.cursor()), + (Some("Normal"), 3) + ); + + let mut textarea = vim_textarea("abc", /*cursor*/ 0); + keys(&mut textarea, "R\nX"); + assert_eq!(textarea.text(), "\nXbc"); + + let mut textarea = vim_textarea("abc def", /*cursor*/ 0); + keys(&mut textarea, "R/?nN"); + assert_eq!(textarea.text(), "/?nNdef"); + escape(&mut textarea); + keys(&mut textarea, "/def\n"); + assert_eq!(textarea.cursor(), 4); +} + +#[test] +fn replace_mode_uses_configured_binding_and_respects_unbinding() { + let mut textarea = vim_textarea("abc", /*cursor*/ 0); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.vim_normal.enter_replace_mode = vec![crate::key_hint::plain(KeyCode::F(2))]; + textarea.set_keymap_bindings(&keymap); + + keys(&mut textarea, "R"); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); + + textarea.input(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + keys(&mut textarea, "X"); + assert_eq!(textarea.text(), "Xbc"); + + escape(&mut textarea); + keymap.vim_normal.enter_replace_mode.clear(); + textarea.set_keymap_bindings(&keymap); + textarea.input(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); +} + +#[test] +fn replace_mode_backspace_restores_original_text() { + let mut textarea = vim_textarea("a👩‍💻", /*cursor*/ 0); + keys(&mut textarea, "RXYZ"); + assert_eq!(textarea.text(), "XYZ"); + + let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + for expected in ["XY", "X👩‍💻", "a👩‍💻", "a👩‍💻"] { + textarea.input(backspace); + assert_eq!(textarea.text(), expected); + } + assert_eq!(textarea.cursor(), 0); + + let mut textarea = vim_textarea("abcd", /*cursor*/ 0); + keys(&mut textarea, "RXY"); + textarea.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + textarea.input(backspace); + assert_eq!(textarea.text(), "Ycd"); + + let mut textarea = vim_textarea("abé", /*cursor*/ 0); + keys(&mut textarea, "RXY"); + textarea.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + textarea.input(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + textarea.input(backspace); + assert_eq!(textarea.text(), ""); + assert_eq!(textarea.vim_mode_label(), Some("Replace")); +} + +#[test] +fn replace_mode_preserves_atomic_attachments() { + for (cursor, command, edited) in [ + (0, "RXY", "XY"), + (1, "RX", "aX"), + ] { + let mut textarea = vim_textarea("ab", /*cursor*/ 1); + textarea.insert_element(""); + textarea.insert_element(""); + textarea.set_cursor(cursor); + let elements = textarea.text_element_snapshots(); + keys(&mut textarea, command); + assert_eq!(textarea.text(), edited); + assert_eq!(textarea.text_element_snapshots(), elements); + assert!(!textarea.retract_paste_burst(/*start*/ 0)); + for _ in command.chars().skip(/*n*/ 1) { + textarea.input(KeyEvent::from(KeyCode::Backspace)); + } + assert_eq!( + ( + textarea.text(), + textarea.cursor(), + textarea.text_element_snapshots(), + ), + ("ab", cursor, elements) + ); + textarea.input(KeyEvent::from(KeyCode::Backspace)); + assert_eq!( + textarea.text(), + if cursor == 0 { + "ab" + } else { + "b" + } + ); + assert_eq!(textarea.element_payloads(), vec!["", ""]); + } +} + +#[test] +fn replace_mode_replays_with_dot() { + let mut textarea = vim_textarea("abc def", /*cursor*/ 0); + keys(&mut textarea, "Rx\u{301}"); + escape(&mut textarea); + assert_eq!(textarea.text(), "x\u{301}c def"); + + keys(&mut textarea, "w."); + assert_eq!(textarea.text(), "x\u{301}c x\u{301}f"); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); + + let mut textarea = vim_textarea("abc def", /*cursor*/ 0); + keys(&mut textarea, "RXY"); + textarea.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + escape(&mut textarea); + assert_eq!(textarea.text(), "Xbc def"); + + keys(&mut textarea, "w."); + assert_eq!(textarea.text(), "Xbc Xef"); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); +} + +#[test] +fn replace_mode_overwrites_explicit_paste() { + let mut textarea = vim_textarea("abcdef", /*cursor*/ 1); + keys(&mut textarea, "R"); + textarea.insert_str("XYZ"); + assert_eq!(textarea.text(), "aXYZef"); + escape(&mut textarea); + + keys(&mut textarea, "0."); + assert_eq!(textarea.text(), "XYZZef"); +} + +#[test] +fn repeat_replays_delete_replace_and_complete_change() { + let mut textarea = vim_textarea("alpha beta gamma", /*cursor*/ 0); + keys(&mut textarea, "dw."); + assert_eq!(textarea.text(), "gamma"); + + let mut textarea = vim_textarea("abc", /*cursor*/ 0); + keys(&mut textarea, "rXl."); + assert_eq!(textarea.text(), "XXc"); + + let mut textarea = vim_textarea("one two three", /*cursor*/ 0); + keys(&mut textarea, "cwX"); + escape(&mut textarea); + keys(&mut textarea, "w."); + assert_eq!(textarea.text(), "X X three"); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); +} + +#[test] +fn repeat_records_pasted_insertions_and_survives_keymap_changes() { + let mut textarea = vim_textarea("", /*cursor*/ 0); + keys(&mut textarea, "i"); + textarea.insert_str("foo"); + escape(&mut textarea); + keys(&mut textarea, "."); + assert_eq!(textarea.text(), "fofooo"); + + let mut textarea = vim_textarea("one two three", /*cursor*/ 0); + keys(&mut textarea, "dw"); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.vim_normal.start_delete_operator = vec![crate::key_hint::plain(KeyCode::Char('z'))]; + textarea.set_keymap_bindings(&keymap); + keys(&mut textarea, "."); + assert_eq!(textarea.text(), "three"); +} + +#[test] +fn repeat_aborts_when_change_motion_cannot_start() { + let mut textarea = vim_textarea("one\ntwo\nthree", /*cursor*/ 0); + keys(&mut textarea, "cjfoo"); + escape(&mut textarea); + keys(&mut textarea, "j"); + let original = (textarea.text().to_owned(), textarea.cursor()); + + keys(&mut textarea, "."); + + assert_eq!( + (textarea.text(), textarea.cursor()), + (original.0.as_str(), original.1) + ); + assert_eq!(textarea.vim_mode_label(), Some("Normal")); +} + +#[test] +fn repeat_distinguishes_insert_mode_deletions_from_noops() { + let mut textarea = vim_textarea("abcd", /*cursor*/ 3); + keys(&mut textarea, "i"); + textarea.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + escape(&mut textarea); + keys(&mut textarea, "l."); + assert_eq!(textarea.text(), "ad"); + + let mut textarea = vim_textarea("abc", /*cursor*/ 0); + keys(&mut textarea, "xi"); + textarea.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + escape(&mut textarea); + keys(&mut textarea, "."); + assert_eq!(textarea.text(), "c"); + + let mut textarea = vim_textarea("one two", /*cursor*/ 0); + keys(&mut textarea, "i"); + textarea.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + keys(&mut textarea, "X"); + escape(&mut textarea); + keys(&mut textarea, "w."); + assert_eq!(textarea.text(), "Xone Xtwo"); +} + +#[test] +fn repeat_omits_ineffective_deletions_before_inserted_text() { + for (action, cursor, repeat_cursor, expected) in [ + (VimAction::DeleteBackward, 0, 5, "Xone Xtwo\n"), + (VimAction::DeleteBackwardWord, 0, 5, "Xone Xtwo\n"), + (VimAction::KillLineStart, 0, 5, "Xone Xtwo\n"), + (VimAction::DeleteForward, 8, 4, "one Xtwo\nX"), + (VimAction::DeleteForwardWord, 8, 4, "one Xtwo\nX"), + (VimAction::KillLineEnd, 8, 4, "one Xtwo\nX"), + (VimAction::KillLine, 8, 4, "one Xtwo\nX"), + ] { + let mut textarea = vim_textarea("one two\n", cursor); + keys(&mut textarea, "i"); + textarea.apply_vim_insert_action(action.clone()); + keys(&mut textarea, "X"); + escape(&mut textarea); + textarea.set_cursor(repeat_cursor); + + keys(&mut textarea, "."); + + assert_eq!(textarea.text(), expected, "{action:?}"); + } +} + +#[test] +fn repeat_replays_resolved_insert_actions_after_editor_keymap_changes() { + let mut textarea = vim_textarea("abc", /*cursor*/ 0); + keys(&mut textarea, "ix"); + textarea.input(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + keys(&mut textarea, "y"); + escape(&mut textarea); + + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + std::sync::Arc::make_mut(&mut keymap.editor).move_left = + vec![crate::key_hint::plain(KeyCode::Char('z'))]; + textarea.set_keymap_bindings(&keymap); + keys(&mut textarea, "."); + + assert_eq!(textarea.text(), "yxyxabc"); +} + +#[test] +fn repeat_replays_literal_tab_insertions() { + let mut textarea = vim_textarea("", /*cursor*/ 0); + keys(&mut textarea, "i"); + textarea.insert_str("\t"); + escape(&mut textarea); + + keys(&mut textarea, "."); + + assert_eq!(textarea.text(), "\t\t"); +} + +#[test] +fn repeat_replays_literal_newlines_with_custom_editor_bindings() { + let mut textarea = vim_textarea("", /*cursor*/ 0); + std::sync::Arc::make_mut(&mut textarea.editor_keymap).insert_newline = + vec![crate::key_hint::plain(KeyCode::Char('n'))]; + keys(&mut textarea, "i"); + textarea.insert_str("\n"); + escape(&mut textarea); + + keys(&mut textarea, "."); + + assert_eq!(textarea.text(), "\n\n"); +} + +#[test] +fn repeat_uses_configured_binding_and_supports_unbinding() { + let mut textarea = vim_textarea("one two three", /*cursor*/ 0); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.vim_normal.repeat_last_change = vec![crate::key_hint::plain(KeyCode::Char('z'))]; + textarea.set_keymap_bindings(&keymap); + + keys(&mut textarea, "dwz"); + assert_eq!(textarea.text(), "three"); + + keymap.vim_normal.repeat_last_change.clear(); + textarea.set_keymap_bindings(&keymap); + keys(&mut textarea, "z"); + assert_eq!(textarea.text(), "three"); +} + +#[test] +fn replacing_the_buffer_discards_incomplete_dot_repeat_history() { + let mut textarea = vim_textarea("", /*cursor*/ 0); + keys(&mut textarea, "iold"); + textarea.set_text_clearing_elements(""); + textarea.enter_vim_normal_mode(); + + keys(&mut textarea, "iX"); + escape(&mut textarea); + keys(&mut textarea, "."); + + assert_eq!(textarea.text(), "XX"); +} + +#[test] +fn replace_character_uses_configured_binding_and_supports_unbinding() { + let mut textarea = vim_textarea("abc", /*cursor*/ 0); + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.vim_normal.replace_char = vec![crate::key_hint::plain(KeyCode::Char('z'))]; + textarea.set_keymap_bindings(&keymap); + + keys(&mut textarea, "zQ"); + assert_eq!(textarea.text(), "Qbc"); + + keymap.vim_normal.replace_char.clear(); + textarea.set_keymap_bindings(&keymap); + keys(&mut textarea, "zR"); + assert_eq!(textarea.text(), "Qbc"); +} + +#[test] +fn change_accepts_word_line_and_repeated_operator_motions() { + let mut textarea = vim_textarea("hello world\nnext", /*cursor*/ 0); + keys(&mut textarea, "cw"); + assert_eq!(textarea.text(), " world\nnext"); + assert_eq!(textarea.vim_mode_label(), Some("Insert")); + + let mut textarea = vim_textarea("hello world\nnext", /*cursor*/ 1); + keys(&mut textarea, "c$"); + assert_eq!(textarea.text(), "h\nnext"); + assert_eq!(textarea.vim_mode_label(), Some("Insert")); + + let mut textarea = vim_textarea("first\nsecond\nthird", /*cursor*/ 8); + keys(&mut textarea, "cc"); + assert_eq!(textarea.text(), "first\n\nthird"); + assert_eq!(textarea.cursor(), "first\n".len()); + keys(&mut textarea, "X"); + escape(&mut textarea); + keys(&mut textarea, "p"); + assert_eq!(textarea.text(), "first\nX\nsecond\nthird"); + + for (text, cursor, command, expected) in [ + ("a b", 0, "cw", " b"), + ("\nnext", 0, "cw", "\nnext"), + ("one\ntwo\nthree", 0, "cj", "\nthree"), + ("one\ntwo\nthree", 8, "ck", "one\n"), + ("one\ntwo\n", 8, "ck", "one\n"), + ("\nnext", 0, "c$", "\nnext"), + ] { + let mut textarea = vim_textarea(text, cursor); + keys(&mut textarea, command); + assert_eq!(textarea.text(), expected); + assert_eq!(textarea.vim_mode_label(), Some("Insert")); + } + for (cursor, command) in [(0, "ck"), (4, "cj")] { + let mut textarea = vim_textarea("one\ntwo", cursor); + keys(&mut textarea, command); + assert_eq!( + (textarea.text(), textarea.vim_mode_label()), + ("one\ntwo", Some("Normal")) + ); + } + + let mut textarea = vim_textarea("hello world", /*cursor*/ 0); + textarea.vim_operator_keymap.motion_word_forward = + vec![crate::key_hint::plain(KeyCode::Char('c'))]; + keys(&mut textarea, "cc"); + assert_eq!(textarea.text(), " world"); +} + +#[test] +fn character_find_is_line_local_and_works_as_operator_motion() { + let mut textarea = vim_textarea("alpha beta\nbeta", /*cursor*/ 0); + keys(&mut textarea, "fb"); + assert_eq!(textarea.cursor(), "alpha ".len()); + keys(&mut textarea, "Fa"); + assert_eq!(textarea.cursor(), "alpha".len() - 1); + keys(&mut textarea, "fz"); + assert_eq!(textarea.cursor(), "alpha".len() - 1); + + for (motion, cursor, destination, range) in [ + ("fr", 0, 3, 0..4), + ("tr", 0, 2, 0..3), + ("Fb", 3, 1, 1..3), + ("Tb", 3, 2, 2..3), + ] { + let text = "abcrdef"; + let mut textarea = vim_textarea(text, cursor); + keys(&mut textarea, motion); + assert_eq!((textarea.text(), textarea.cursor()), (text, destination)); + for operator in ['c', 'd', 'y'] { + let mut textarea = vim_textarea(text, cursor); + keys(&mut textarea, &format!("{operator}{motion}")); + let expected = if operator == 'y' { + text.to_owned() + } else { + format!("{}{}", &text[..range.start], &text[range.end..]) + }; + let mode = if operator == 'c' { "Insert" } else { "Normal" }; + assert_eq!( + ( + textarea.text(), + textarea.cursor(), + textarea.vim_mode_label(), + textarea.kill_buffer.as_str() + ), + ( + expected.as_str(), + range.start, + Some(mode), + &text[range.clone()] + ), + "{operator}{motion}", + ); + } + } + + let mut textarea = vim_textarea("\nabc", /*cursor*/ 0); + keys(&mut textarea, "fx"); + assert_eq!(textarea.text(), "\nabc"); + assert_eq!(textarea.cursor(), 0); +} + +#[test] +fn find_and_till_handle_missing_cancelled_and_adjacent_targets() { + for motion in ['f', 'F', 't', 'T'] { + for operator in ["", "c", "d", "y"] { + let mut textarea = vim_textarea("abc\nz", /*cursor*/ 1); + keys(&mut textarea, &format!("{operator}{motion}z")); + assert_eq!( + ( + textarea.text(), + textarea.cursor(), + textarea.vim_mode_label() + ), + ("abc\nz", 1, Some("Normal")) + ); + keys(&mut textarea, &format!("{operator}{motion}")); + escape(&mut textarea); + assert!(!textarea.is_vim_operator_pending()); + } + } + for (cursor, command, expected) in [(0, "ctb", "bc"), (2, "cTb", "abc")] { + let mut textarea = vim_textarea("abc", cursor); + keys(&mut textarea, command); + assert_eq!( + (textarea.text(), textarea.vim_mode_label()), + (expected, Some("Insert")) + ); + } +} + +#[test] +fn dot_repeat_replays_character_find_and_buffer_jump_operators() { + let mut textarea = vim_textarea("one:two:three", /*cursor*/ 0); + keys(&mut textarea, "df:."); + assert_eq!(textarea.text(), "three"); + + for (cursor, command, repeat, expected) in [ + (0, "cfrX", "w.", "X X"), + (0, "ctrX", "w.", "Xr Xr"), + (3, "cFbX", concat!("w", "lll."), "aXr aXr"), + (3, "cTbX", concat!("w", "lll."), "abXr abXr"), + ] { + let mut textarea = vim_textarea("abcr abcr", cursor); + keys(&mut textarea, command); + escape(&mut textarea); + keys(&mut textarea, repeat); + assert_eq!( + (textarea.text(), textarea.vim_mode_label()), + (expected, Some("Normal")) + ); + } + + let mut textarea = vim_textarea("one\ntwo\nthree\nfour\nfive", "one\n".len()); + keys(&mut textarea, "dggj."); + assert_eq!(textarea.text(), "five"); +} + +#[test] +fn character_find_never_splits_extended_graphemes() { + let mut textarea = vim_textarea("a👩‍💻z", /*cursor*/ 0); + keys(&mut textarea, "f💻"); + assert_eq!(textarea.cursor(), 0); + keys(&mut textarea, "f👩rX"); + assert_eq!(textarea.text(), "aXz"); + for (text, command, expected, cursor) in [ + ("a👩‍💻r", "tr", "a👩‍💻r", 1), + ("aहिr", "tr", "aहिr", 1), + ("aहिr", "dfह", "r", 0), + ] { + let mut textarea = vim_textarea(text, /*cursor*/ 0); + keys(&mut textarea, command); + assert_eq!((textarea.text(), textarea.cursor()), (expected, cursor)); + } + let mut textarea = vim_textarea("a", /*cursor*/ 0); + textarea.set_cursor(/*pos*/ 1); + textarea.insert_element("[image]"); + textarea.insert_str("r"); + textarea.set_cursor(/*pos*/ 0); + keys(&mut textarea, "t]"); + assert_eq!(textarea.cursor(), 0); + keys(&mut textarea, "tr"); + assert_eq!(textarea.cursor(), 1); + textarea.set_cursor(/*pos*/ 0); + keys(&mut textarea, "ctr"); + assert_eq!((textarea.text(), textarea.elements.len()), ("r", 0)); + + let prefix = "earlier line\na"; + for (command, cursor) in [("Fa", prefix.len() - 1), ("Ta", prefix.len())] { + let mut textarea = vim_textarea(prefix, /*cursor*/ 0); + textarea.set_cursor(prefix.len()); + textarea.insert_element("[aaaa]"); + textarea.insert_str("z"); + textarea.set_cursor(textarea.text().len() - 1); + keys(&mut textarea, command); + assert_eq!( + (textarea.text(), textarea.cursor()), + ("earlier line\na[aaaa]z", cursor) + ); + } +} + +#[test] +fn character_find_and_operator_motion_use_configured_bindings() { + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.vim_normal.find_forward = vec![crate::key_hint::plain(KeyCode::Char('z'))]; + keymap.vim_operator.motion_find_forward = vec![crate::key_hint::plain(KeyCode::Char('z'))]; + keymap.vim_normal.till_forward = vec![crate::key_hint::plain(KeyCode::Char('q'))]; + keymap.vim_operator.motion_till_forward = vec![crate::key_hint::plain(KeyCode::Char('q'))]; + for (motion, cursor, expected) in [("zb", 6, "eta gamma"), ("qb", 5, "beta gamma")] { + let mut textarea = vim_textarea("alpha beta gamma", /*cursor*/ 0); + textarea.set_keymap_bindings(&keymap); + keys(&mut textarea, motion); + assert_eq!(textarea.cursor(), cursor); + textarea.set_cursor(/*pos*/ 0); + keys(&mut textarea, &format!("d{motion}")); + assert_eq!(textarea.text(), expected); + } +} + +#[test] +fn uppercase_commands_accept_shift_only_terminal_events() { + let mut textarea = vim_textarea("alpha\nbeta", /*cursor*/ 0); + textarea.input(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::SHIFT)); + assert_eq!(textarea.cursor(), "alpha\n".len()); + + textarea.set_cursor("alpha\nbe".len()); + textarea.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::SHIFT)); + keys(&mut textarea, "b"); + assert_eq!(textarea.cursor(), "alpha\n".len()); + textarea.set_cursor("alpha\nbe".len()); + textarea.input(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::SHIFT)); + keys(&mut textarea, "b"); + assert_eq!(textarea.cursor(), "alpha\nb".len()); +} + +#[test] +fn buffer_jumps_target_first_non_blank_and_support_operators() { + let mut textarea = vim_textarea(" first\n second\n third", /*cursor*/ 2); + keys(&mut textarea, "G"); + assert_eq!(textarea.cursor(), " first\n second\n ".len()); + keys(&mut textarea, "gg"); + assert_eq!(textarea.cursor(), 2); + + let mut textarea = vim_textarea("first\nsecond\nthird", "first\n".len()); + keys(&mut textarea, "dG"); + assert_eq!(textarea.text(), "first\n"); + + let mut textarea = vim_textarea("first\nsecond\nthird", "first\n".len()); + keys(&mut textarea, "dgg"); + assert_eq!(textarea.text(), "third"); + + let mut textarea = vim_textarea("one\ntwo\nthree\nfour", "one\n".len()); + keys(&mut textarea, "dggp"); + assert_eq!(textarea.text(), "three\none\ntwo\nfour"); + + let mut textarea = vim_textarea("one\ntwo\nthree", "one\n".len()); + keys(&mut textarea, "yGp"); + assert_eq!(textarea.text(), "one\ntwo\ntwo\nthree\nthree"); + + let mut textarea = vim_textarea("one\ntwo\nthree", "one\n".len()); + keys(&mut textarea, "cG"); + assert_eq!(textarea.vim_mode_label(), Some("Insert")); +} + +#[test] +fn pending_replacement_owns_escape_before_turn_interruption() { + let mut textarea = vim_textarea("alpha", /*cursor*/ 0); + let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + assert!(!textarea.should_handle_vim_insert_escape(escape)); + + keys(&mut textarea, "r"); + assert!(textarea.should_handle_vim_insert_escape(escape)); + textarea.input(escape); + assert!(!textarea.should_handle_vim_insert_escape(escape)); + assert_eq!(textarea.text(), "alpha"); +} + +#[test] +fn editor_commands_have_visual_snapshot_coverage() { + let mut textarea = vim_textarea("alpha beta\ngamma delta", /*cursor*/ 0); + let mut states = Vec::new(); + for command in ["rZ", "cw"] { + keys(&mut textarea, command); + states.push(format!( + "{command}: {}\n{}^", + textarea.text().replace('\n', "\\n"), + " ".repeat(textarea.cursor()) + )); + } + insta::assert_snapshot!(states.join("\n\n"), @r###" + rZ: Zlpha beta\ngamma delta + ^ + + cw: beta\ngamma delta + ^ + "###); +} + +#[test] +fn dot_repeat_has_visual_snapshot_coverage() { + let mut textarea = vim_textarea("alpha beta\ngamma delta", /*cursor*/ 0); + let mut states = Vec::new(); + for command in ["dw", "."] { + keys(&mut textarea, command); + states.push(format!( + "{command}: {}\n{}^", + textarea.text().replace('\n', "\\n"), + " ".repeat(textarea.cursor()) + )); + } + insta::assert_snapshot!(states.join("\n\n"), @r###" + dw: beta\ngamma delta + ^ + + .: gamma delta + ^ + "###); +} + +#[test] +fn find_and_navigation_have_visual_snapshot_coverage() { + let mut textarea = vim_textarea("alpha beta\ngamma delta", /*cursor*/ 0); + let mut states = Vec::new(); + for command in ["tb", "fb", "Ta", "Fa", "G", "gg"] { + keys(&mut textarea, command); + states.push(format!( + "{command}: {}\n{}^", + textarea.text().replace('\n', "\\n"), + " ".repeat(textarea.cursor()) + )); + } + insta::assert_snapshot!(states.join("\n\n"), @r###" + tb: alpha beta\ngamma delta + ^ + + fb: alpha beta\ngamma delta + ^ + + Ta: alpha beta\ngamma delta + ^ + + Fa: alpha beta\ngamma delta + ^ + + G: alpha beta\ngamma delta + ^ + + gg: alpha beta\ngamma delta + ^ + "###); +} + +#[test] +fn search_motions_compose_with_operators_and_repeat() { + for (commands, text, cursor, mode) in [ + ("/b\n", "a b c b d b", 2, "Normal"), + ("/b\nn", "a b c b d b", 6, "Normal"), + ("/b\nnN", "a b c b d b", 2, "Normal"), + ("/b\nnnn", "a b c b d b", 2, "Normal"), + ("?b\n", "a b c b d b", 10, "Normal"), + ("?b\nn", "a b c b d b", 6, "Normal"), + ("?b\nN", "a b c b d b", 2, "Normal"), + ("d/b\n", "b c b d b", 0, "Normal"), + ("c/b\n", "b c b d b", 0, "Insert"), + ("y/b\np", "aa b c b d b", 3, "Normal"), + ("/b\ndn", "a b d b", 2, "Normal"), + ("?b\ndN", "a b", 2, "Normal"), + ("?b\nd?b\n", "a b c b", 6, "Normal"), + ("?b\ny?b\n", "a b c b d b", 6, "Normal"), + ("d/missing\n", "a b c b d b", 0, "Normal"), + ("/b\nc?missing\x1bn", "a b c b d b", 6, "Normal"), + ("c/b\nX\x1bl.", "XXb d b", 1, "Normal"), + ] { + let mut area = vim_textarea("a b c b d b", /*cursor*/ 0); + keys(&mut area, commands); + assert_eq!( + (area.text(), area.cursor(), area.vim_mode_label()), + (text, cursor, Some(mode)), + "{commands:?}" + ); + } + for (cursor, commands, expected) in [ + (1, "d/xyz\n", "a\nxyz"), + (0, "d/xyz\n", "xyz"), + (0, "c/xyz\nQ\x1b", "Q\nxyz"), + (1, "c/xyz\nQ\x1b", "aQ\nxyz"), + (0, "y/xyz\np", "abc\nabc\nxyz"), + ] { + let mut area = vim_textarea("abc\nxyz", cursor); + keys(&mut area, commands); + assert_eq!(area.text(), expected, "{commands:?}"); + } +} + +#[test] +fn search_skips_atomic_elements_and_partial_graphemes() { + let mut area = vim_textarea("é e\u{301}\n", /*cursor*/ 0); + area.insert_element("[photo.png]"); + keys(&mut area, "/photo\n"); + assert_eq!(area.cursor(), "[photo.png]".len()); + keys(&mut area, "/\u{301}\n"); + assert_eq!(area.cursor(), "[photo.png]".len()); + keys(&mut area, "/e\u{301}\n"); + assert_eq!(area.cursor(), "[photo.png]é ".len()); +} diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs b/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs new file mode 100644 index 0000000000000000000000000000000000000000..4bc7451eb31ddebc1a9b264dda5189554ef29b67 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim_search.rs @@ -0,0 +1,251 @@ +//! Draft-local search motions and operator transactions. Query entry never edits the buffer; +//! accepted searches wrap, skip atomic elements, and record patterns rather than byte offsets. + +use super::TextArea; +use super::TextAreaState; +use super::VimAction; +use super::VimEditTarget; +use super::VimOperator; +use super::VimPending; +use crate::key_hint::KeyBindingListExt; +use crate::keymap::KeymapContext; +use crate::keymap::KeymapContextSet; +use crate::vim_search::SearchDirection; +use crate::vim_search::SearchQuery; +use crate::vim_search::matching_ranges; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; +use std::ops::Range; + +#[derive(Debug, Default)] +pub(super) struct VimSearch { + input: Option>, + pub(super) last: SearchQuery, +} + +/// Footer query editor, boxed by its owning textarea to keep the draft and query independent. +#[derive(Debug)] +pub(crate) struct SearchInput { + pub(crate) editor: TextArea, + direction: SearchDirection, +} + +impl SearchInput { + pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) { + let prefix = match self.direction { + SearchDirection::Forward => "/", + SearchDirection::Backward => "?", + }; + prefix.cyan().render(area, buf); + StatefulWidgetRef::render_ref( + &&self.editor, + Self::query_area(area), + buf, + &mut TextAreaState::default(), + ); + } + + pub(crate) fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.editor.cursor_pos(Self::query_area(area)) + } + + fn query_area(area: Rect) -> Rect { + Rect { + x: area.x.saturating_add(1), + width: area.width.saturating_sub(1), + ..area + } + } +} + +enum SearchCommand { + Start(SearchDirection), + Next, + Previous, +} + +impl TextArea { + pub(crate) fn keymap_contexts(&self) -> KeymapContextSet { + let context = self.keymap_context(); + let contexts = KeymapContextSet::new(context); + if self.vim_search_enabled + && matches!( + context, + KeymapContext::VimNormal | KeymapContext::VimOperator + ) + { + contexts.with(KeymapContext::VimSearch) + } else { + contexts + } + } + + pub(crate) fn enable_vim_search(&mut self) { + self.vim_search_enabled = true; + } + pub(crate) fn vim_query(&self) -> Option<&SearchInput> { + self.vim_search.input.as_deref() + } + pub(crate) fn vim_query_mut(&mut self) -> Option<&mut SearchInput> { + self.vim_search.input.as_deref_mut() + } + pub(crate) fn cancel_vim_search(&mut self) -> bool { + self.vim_search.input.take().is_some_and(|_| { + self.vim_pending = VimPending::None; + true + }) + } + + pub(crate) fn wants_vim_search_key(&self, event: KeyEvent) -> bool { + self.vim_search.input.is_some() || self.vim_search_command(event).is_some() + } + + fn vim_search_command(&self, event: KeyEvent) -> Option { + use SearchCommand::Start; + + if !self.vim_search_enabled + || !self.is_vim_normal_mode() + || !matches!(self.vim_pending, VimPending::None | VimPending::Operator(_)) + { + return None; + } + let keys = &self.vim_search_keymap; + [ + (&keys.forward, Start(SearchDirection::Forward)), + (&keys.backward, Start(SearchDirection::Backward)), + (&keys.next, SearchCommand::Next), + (&keys.previous, SearchCommand::Previous), + ] + .into_iter() + .find_map(|(bindings, command)| bindings.is_pressed(event).then_some(command)) + } + + pub(super) fn handle_vim_search_key(&mut self, event: KeyEvent) -> bool { + let query = if let Some(mut input) = self.vim_search.input.take() { + match (event.code, event.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.vim_pending = VimPending::None; + return true; + } + (KeyCode::Backspace, KeyModifiers::NONE) if input.editor.is_empty() => { + self.vim_pending = VimPending::None; + return true; + } + (KeyCode::Enter, KeyModifiers::NONE) => {} + _ => { + input.editor.input_with_keymap(event, &self.editor_keymap); + self.vim_search.input = Some(input); + return true; + } + } + let text = if input.editor.is_empty() { + self.vim_search.last.text.clone() + } else { + input.editor.text().to_owned() + }; + let query = SearchQuery { + text, + direction: input.direction, + }; + self.vim_search.last = query.clone(); + query + } else { + let Some(command) = self.vim_search_command(event) else { + return false; + }; + if let SearchCommand::Start(direction) = command { + self.vim_search.input = Some(Box::new(SearchInput { + editor: TextArea::new(), + direction, + })); + return true; + } + let mut query = self.vim_search.last.clone(); + if matches!(command, SearchCommand::Previous) { + query.direction = query.direction.reversed(); + } + query + }; + match std::mem::replace(&mut self.vim_pending, VimPending::None) { + VimPending::Operator(VimOperator::Delete) => { + self.start_vim_edit(VimAction::Delete(VimEditTarget::Search(query))); + } + VimPending::Operator(VimOperator::Change) => { + self.start_vim_edit(VimAction::Change(VimEditTarget::Search(query))); + } + VimPending::Operator(VimOperator::Yank) => { + self.apply_vim_search(&query, Some(VimOperator::Yank)); + } + _ => { + self.apply_vim_search(&query, /*operator*/ None); + } + } + true + } + + pub(super) fn apply_vim_search( + &mut self, + query: &SearchQuery, + operator: Option, + ) -> bool { + let origin = self.cursor_pos; + // Prefer matches before wrapping; order backward matches from the end. + let target = matching_ranges(&self.text, &query.text) + .filter(|range| self.is_vim_command_target(range.start)) + .map(|range| range.start) + .min_by_key(|&pos| match query.direction { + SearchDirection::Forward => (pos <= origin, pos), + SearchDirection::Backward => (pos >= origin, usize::MAX - pos), + }); + let Some(target) = target else { + return false; + }; + if let Some(operator) = operator { + if origin == target { + return false; + } + let mut range = origin.min(target)..origin.max(target); + let line_start = self.beginning_of_line(range.start); + let ends_at_line_start = range.end == self.beginning_of_line(range.end); + // Exclusive motions ending at column zero may become linewise. + let linewise = + ends_at_line_start && self.text[line_start..range.start].trim().is_empty(); + if linewise { + range.start = line_start; + } else if ends_at_line_start { + range.end -= 1; + } + let start = range.start; + if operator == VimOperator::Yank { + self.set_cursor(start); + } + self.apply_vim_operator_to_range(operator, range); + if linewise { + self.kill_buffer_kind = super::KillBufferKind::Linewise; + if operator == VimOperator::Change { + self.insert_str_at(start, "\n"); + self.set_cursor(start); + } + } + } else { + self.set_cursor(target); + } + true + } + + pub(crate) fn vim_search_highlights(&self) -> Vec> { + let query = match self.vim_query() { + Some(input) => input.editor.text(), + None => &self.vim_search.last.text, + }; + matching_ranges(&self.text, query) + .filter(|range| self.is_vim_command_target(range.start)) + .collect() + } +} diff --git a/codex-rs/tui/src/bottom_pane/textarea/wrapping.rs b/codex-rs/tui/src/bottom_pane/textarea/wrapping.rs new file mode 100644 index 0000000000000000000000000000000000000000..1250ba71bf814881c0828851321d151bfa256606 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/wrapping.rs @@ -0,0 +1,225 @@ +//! Grapheme-safe composer wrapping that preserves textwrap's semantic breakpoints. +//! +//! Source ranges include a sentinel byte for cursor placement. Separators at soft breaks hang off +//! the preceding row without changing the source text. Full logical lines reserve an insertion row. + +use super::text_for_display; +use crate::width::display_width; +use std::ops::Range; +use textwrap::Options; +use unicode_segmentation::UnicodeSegmentation; + +/// Cached source span and display width for a word identified by `textwrap`. +struct WrappedWord { + range: Range, + width: usize, +} + +/// Classifies breakable whitespace; the three Unicode nonbreaking spaces stay in their word. +fn is_breakable_space(ch: char) -> bool { + ch.is_whitespace() && !matches!(ch, '\u{a0}' | '\u{2007}' | '\u{202f}') +} + +/// Mandatory Unicode breaks retain their editable insertion positions instead of hanging. +fn is_hangable_space(ch: char) -> bool { + is_breakable_space(ch) + && !matches!( + ch, + '\n' | '\r' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' + ) +} + +/// Drops a row's hidden suffix once soft whitespace alone fills the viewport. +/// +/// Each hangable separator occupies at least one rendered cell. Keeping the complete grapheme +/// preserves any combining marks on the last visible space, while bounding work on long runs. +pub(super) fn visible_prefix(text: &str, width: u16) -> &str { + if width == 0 { + return ""; + } + if text.len() <= usize::from(width) { + return text; + } + text.grapheme_indices(/*is_extended*/ true) + .filter(|(_, grapheme)| grapheme.chars().any(is_hangable_space)) + .nth(usize::from(width) - 1) + .map_or(text, |(start, grapheme)| &text[..start + grapheme.len()]) +} + +/// Returns grapheme-safe visual ranges, each including its cursor-position sentinel byte. +/// +/// Interior breakable whitespace can extend past the row width, but indentation and trailing +/// whitespace remain visible and editable. Nonbreaking whitespace remains part of its word, and +/// full logical lines receive an empty insertion row. Word boundaries and widths are indexed once +/// so maximum-size pasted lines remain linear to wrap. +pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec> { + let width = usize::from(width); + let options = Options::new(width).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit); + let wrapped_lines = crate::wrapping::wrap_ranges(text, &options); + if width == 0 || (wrapped_lines.len() == 1 && text.len() < width) { + return wrapped_lines; + } + + let mut breakpoints = vec![false; text.len() + 1]; + let mut words = Vec::new(); + let mut logical_start = 0; + for logical_line in text.split('\n') { + let mut word_start = logical_start; + for word in options.word_separator.find_words(logical_line) { + let word_end = word_start + word.word.len(); + for breakpoint in options.word_splitter.split_points(word.word) { + breakpoints[word_start + breakpoint] = true; + } + breakpoints[word_end] = true; + words.push(WrappedWord { + range: word_start..word_end, + width: display_width(word.word), + }); + word_start = word_end + word.whitespace.len(); + } + logical_start += logical_line.len() + 1; + } + + let mut lines = Vec::with_capacity(wrapped_lines.len()); + let mut line_start = 0; + let mut line_width = 0; + let mut line_has_text = false; + let mut line_active = false; + let mut logical_content_end = 0; + let mut processed_end = 0; + let mut previous_was_whitespace = false; + let mut word_index = 0; + let mut consumed_word_width = 0; + + for wrapped_line in wrapped_lines { + let line_end = wrapped_line.end.saturating_sub(1); + // textwrap can repeat leading whitespace around punctuation boundaries. + let fragment_start = wrapped_line.start.max(processed_end); + if fragment_start > line_end { + continue; + } + + while word_index < words.len() && words[word_index].range.end <= fragment_start { + word_index += 1; + consumed_word_width = 0; + } + + if !line_active { + line_start = fragment_start; + line_active = true; + let logical_line = text[fragment_start..] + .split('\n') + .next() + .unwrap_or_default(); + logical_content_end = + fragment_start + logical_line.trim_end_matches(is_hangable_space).len(); + } else if line_has_text && breakpoints[fragment_start] { + let remaining_word_width = words + .get(word_index) + .filter(|word| word.range.contains(&fragment_start)) + .map_or(0, |word| word.width.saturating_sub(consumed_word_width)); + // Keep textwrap's break if crossing it would split the next word. + if line_width + remaining_word_width > width { + lines.push(line_start..fragment_start + 1); + line_start = fragment_start; + line_width = 0; + line_has_text = false; + } + } + + for (offset, grapheme) in + text[fragment_start..line_end].grapheme_indices(/*is_extended*/ true) + { + let grapheme_start = fragment_start + offset; + while word_index < words.len() && words[word_index].range.end <= grapheme_start { + word_index += 1; + consumed_word_width = 0; + } + + let grapheme_end = grapheme_start + grapheme.len(); + let is_whitespace = grapheme.chars().all(is_breakable_space); + // Partial whitespace rows absorb the next word; text rows keep it intact. + if !is_whitespace && line_has_text && previous_was_whitespace { + let word_end = words + .get(word_index) + .filter(|word| word.range.contains(&grapheme_start)) + .map_or(line_end, |word| word.range.end.min(line_end)); + if line_width + display_width(&text[grapheme_start..word_end]) > width { + lines.push(line_start..grapheme_start + 1); + line_start = grapheme_start; + line_width = 0; + line_has_text = false; + } + } + + let grapheme_width = display_width(grapheme); + let hanging_space = line_has_text + && grapheme_end < logical_content_end + && grapheme.chars().all(is_hangable_space); + if !hanging_space && line_width > 0 && line_width + grapheme_width > width { + lines.push(line_start..grapheme_start + 1); + line_start = grapheme_start; + line_width = 0; + line_has_text = false; + } + line_width += grapheme_width; + line_has_text |= !is_whitespace; + previous_was_whitespace = is_whitespace; + + if words + .get(word_index) + .is_some_and(|word| word.range.contains(&grapheme_start)) + { + consumed_word_width += grapheme_width; + } + } + + if matches!(text.as_bytes().get(line_end), None | Some(b'\n')) { + lines.push(line_start..line_end + 1); + if line_width >= width { + lines.push(line_end..line_end + 1); + } + line_width = 0; + line_has_text = false; + line_active = false; + previous_was_whitespace = false; + } + processed_end = line_end; + } + + lines +} + +/// Maps a source insertion point to a zero-based visual row and column. +/// +/// `lines` must come from [`wrapped_lines`] for the display-normalized text and the same width, +/// and `pos` must be a valid source boundary. The measured prefix uses the same normalization. +/// Positions in overflowing separators share the following row's first column without changing +/// their distinct editing offsets. Other columns stay inside the viewport. +pub(super) fn cursor_position( + text: &str, + lines: &[Range], + width: u16, + pos: usize, +) -> Option<(usize, usize)> { + let row = lines + .partition_point(|line| line.start <= pos) + .checked_sub(1)?; + let before_cursor = visible_prefix(&text[lines[row].start..pos], width); + let before_cursor = text_for_display(before_cursor); + let col = display_width(before_cursor.as_ref()); + let width = usize::from(width); + if col >= width + && lines + .get(row + 1) + .is_some_and(|next| next.start == lines[row].end - 1) + { + Some((row + 1, 0)) + } else { + Some((row, col.min(width.saturating_sub(1)))) + } +} + +#[cfg(test)] +#[path = "wrapping_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/textarea/wrapping_tests.rs b/codex-rs/tui/src/bottom_pane/textarea/wrapping_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..08557b8e9db741964cbd90730d8150513599bd48 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/wrapping_tests.rs @@ -0,0 +1,416 @@ +use super::super::TextArea; +use super::super::TextAreaState; +use super::visible_prefix; +use super::wrapped_lines; +use crate::width::display_width; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use pretty_assertions::assert_eq; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::WidgetRef; + +fn wrapped_rows(text: &str, width: u16) -> Vec<&str> { + wrapped_lines(text, width) + .into_iter() + .map(|range| &text[range.start..range.end - 1]) + .collect() +} + +#[test] +fn vertical_navigation_preserves_destination_tab_columns() { + let mut t = TextArea::new(); + t.insert_str("a\tb\nxyz"); + let area = Rect::new(0, 0, /*width*/ 8, /*height*/ 2); + t.set_cursor(/*pos*/ 5); + let _ = t.desired_height(area.width); + t.move_cursor_up(); + assert_eq!((t.cursor(), t.cursor_pos(area)), (1, Some((1, 0)))); + t.move_cursor_down(); + assert_eq!((t.cursor(), t.cursor_pos(area)), (5, Some((1, 1)))); +} + +#[test] +fn mandatory_breaks_keep_distinct_cursor_positions() { + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3); + for separator in ["\u{b}", "\u{c}", "\u{85}", "\u{2028}", "\u{2029}", "\r"] { + let text = format!("abad{separator}next"); + let rows = wrapped_rows(&text, area.width); + assert_eq!(rows, ["abad", &format!("{separator}nex"), "t"]); + + let mut t = TextArea::new(); + t.insert_str(&text); + t.set_cursor(/*pos*/ 4); + let before = t.cursor_pos(area); + t.move_cursor_right(); + let after = (t.cursor(), t.cursor_pos(area)); + assert_eq!( + (before, after), + (Some((0, 1)), (4 + separator.len(), Some((1, 1)))) + ); + t.move_cursor_left(); + assert_eq!((t.cursor(), t.cursor_pos(area)), (4, before)); + assert_eq!(t.text(), text); + + if separator == "\u{2028}" { + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + terminal + .draw(|frame| { + WidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut()); + }) + .unwrap(); + insta::assert_snapshot!( + "mandatory_break_cursor", + format!( + "before: {before:?}\nafter: {after:?}\n{}", + terminal.backend() + ) + .replace(separator, "\\u{2028}") + ); + } + } +} + +#[test] +fn visible_prefix_bounds_long_hanging_runs_without_splitting_graphemes() { + let width = 80; + let count = MAX_USER_INPUT_TEXT_CHARS - 2; + let area = Rect::new(0, 0, width, /*height*/ 2); + for separator in [" ", "\t", "\u{2003}", "\u{3000}"] { + let text = format!("a{}x", separator.repeat(count)); + assert_eq!( + visible_prefix(&text, width), + format!("a{}", separator.repeat(usize::from(width))) + ); + let mut t = TextArea::new(); + t.insert_str(&text); + t.set_cursor(text.len() - 1 - separator.len()); + assert_eq!(t.cursor_pos(area), Some((0, 1))); + assert_eq!(t.text(), text); + } + assert_eq!( + visible_prefix("a \u{301} hidden", /*width*/ 2), + "a \u{301}" + ); +} + +#[test] +fn preserves_textwrap_word_boundaries() { + for (text, width, expected_rows) in [ + ("a foo-barbaz", 10, ["a foo-", "barbaz"]), + ("a foo-barbazqux", 10, ["a foo-", "barbazqux"]), + ("a café-barbaz", 10, ["a café-", "barbaz"]), + ("a foo/barbaz", 10, ["a foo/", "barbaz"]), + ("a foo—barbaz", 10, ["a foo—", "barbaz"]), + ("a abc\u{a0}de", 7, ["a ", "abc\u{a0}de"]), + ("a abc\u{2007}de", 7, ["a ", "abc\u{2007}de"]), + ("a abc\u{202f}de", 7, ["a ", "abc\u{202f}de"]), + ("a \u{a0}abc", 5, ["a ", "\u{a0}abc"]), + ] { + let rows = wrapped_lines(text, width) + .iter() + .map(|range| &text[range.start..range.end - 1]) + .collect::>(); + + assert_eq!(rows, expected_rows, "text={text:?}, width={width}"); + } +} + +#[test] +fn breakable_unicode_spaces_hang_before_following_words() { + for (text, expected_rows) in [ + ("abad abcde", ["abad ", "abcd", "e"]), + ("abad\u{2003}abcde", ["abad\u{2003}", "abcd", "e"]), + ( + "abad\u{2003}\u{2003}abcde", + ["abad\u{2003}\u{2003}", "abcd", "e"], + ), + ("abad\u{3000}abcde", ["abad\u{3000}", "abcd", "e"]), + ( + "abad\u{3000} \u{2003}abcde", + ["abad\u{3000} \u{2003}", "abcd", "e"], + ), + ] { + assert_eq!( + wrapped_rows(text, /*width*/ 4), + expected_rows, + "text={text:?}" + ); + } +} + +#[test] +fn hanging_spaces_preserve_source_editing_and_visual_navigation() { + let text = "abad next"; + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 3); + let mut t = TextArea::new(); + t.insert_str(text); + assert_eq!(t.desired_height(area.width), 3); + assert_eq!(t.text(), text); + + for cursor in 4..=6 { + t.set_cursor(cursor); + assert_eq!(t.cursor_pos(area), Some((0, 1))); + } + t.move_cursor_left(); + assert_eq!(t.cursor(), 5); + t.move_cursor_up(); + assert_eq!(t.cursor(), 0); + t.move_cursor_down(); + assert_eq!(t.cursor(), 6); + t.delete_backward(/*n*/ 1); + assert_eq!(t.text(), "abad next"); + assert_eq!(t.cursor_pos(area), Some((0, 1))); + t.delete_backward(/*n*/ 1); + assert_eq!(t.text(), "abadnext"); + assert_eq!(t.cursor_pos(area), Some((0, 1))); +} + +#[test] +fn hanging_tabs_keep_cursor_scrolling_and_navigation_aligned() { + let text = "abc\t a"; + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 2); + let mut t = TextArea::new(); + t.insert_str(text); + let cursors = (0..=text.len()) + .map(|pos| { + t.set_cursor(pos); + t.cursor_pos(area) + }) + .collect::>(); + assert_eq!( + cursors, + [(0, 0), (1, 0), (2, 0), (3, 0), (0, 1), (0, 1), (1, 1)].map(Some) + ); + + t.set_cursor(/*pos*/ 4); + let mut state = TextAreaState::default(); + let mut terminal = Terminal::new(TestBackend::new(area.width, /*height*/ 1)).unwrap(); + terminal + .draw(|frame| { + StatefulWidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut(), &mut state); + }) + .unwrap(); + let scrolled_cursor = t.cursor_pos_with_state(terminal.backend().buffer().area, state); + assert_eq!((state.scroll, scrolled_cursor), (1, Some((0, 0)))); + insta::assert_snapshot!( + "hanging_tab_cursor_and_scroll", + format!( + "cursors: {cursors:?}\nscroll: {}\ncursor: {scrolled_cursor:?}\n{}", + state.scroll, + terminal.backend() + ) + ); + + t.move_cursor_up(); + assert_eq!((t.cursor(), t.cursor_pos(area)), (0, Some((0, 0)))); + t.move_cursor_down(); + assert_eq!((t.cursor(), t.cursor_pos(area)), (5, Some((0, 1)))); + assert_eq!(t.text(), text); +} + +#[test] +fn vertical_navigation_clamps_saved_column_after_resize() { + let text = "abcdefghij\nabcd xyz"; + let mut t = TextArea::new(); + t.insert_str(text); + let mut cursors = Vec::new(); + let mut record = |t: &TextArea, label: &str, width: u16, expected| { + let area = Rect::new(0, 0, width, t.desired_height(width)); + let cursor = (t.cursor(), t.cursor_pos(area)); + assert_eq!(cursor, expected, "{label}"); + cursors.push(format!("{label}: {cursor:?}")); + }; + + let _ = t.desired_height(/*width*/ 10); + t.move_cursor_up(); + record(&t, "up at width 10", /*width*/ 10, (10, Some((0, 1)))); + record( + &t, + "resize to width 4", + /*width*/ 4, + (10, Some((2, 2))), + ); + t.move_cursor_down(); + record(&t, "down to abcd", /*width*/ 4, (14, Some((3, 3)))); + t.move_cursor_down(); + record(&t, "down to xyz", /*width*/ 4, (20, Some((3, 4)))); + t.move_cursor_up(); + record(&t, "up to abcd", /*width*/ 4, (14, Some((3, 3)))); + t.move_cursor_up(); + record(&t, "up to ij", /*width*/ 4, (10, Some((2, 2)))); + let _ = t.desired_height(/*width*/ 10); + t.move_cursor_down(); + record( + &t, + "restore width 10", + /*width*/ 10, + (20, Some((9, 2))), + ); + + assert_eq!(t.text(), text); + let layouts = [4, 10].map(|width| { + let mut terminal = Terminal::new(TestBackend::new(width, t.desired_height(width))).unwrap(); + terminal + .draw(|frame| { + WidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut()); + }) + .unwrap(); + format!("width {width}\n{}", terminal.backend()) + }); + insta::assert_snapshot!( + "vertical_navigation_after_resize", + format!("{}\n\n{}", cursors.join("\n"), layouts.join("\n")) + ); +} + +#[test] +fn typing_after_eol_spaces_reflows_without_changing_text() { + let area = Rect::new(0, 0, /*width*/ 4, /*height*/ 2); + let mut t = TextArea::new(); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + let mut snapshots = Vec::new(); + for (label, input, expected_cursor) in [ + ("full line", "abad", (0, 1)), + ("trailing spaces", " ", (3, 1)), + ("following word", "x", (1, 1)), + ("delete following word", "", (3, 1)), + ] { + if input.is_empty() { + t.delete_backward(/*n*/ 1); + } else { + t.insert_str(input); + } + terminal + .draw(|frame| { + WidgetRef::render_ref(&(&t), frame.area(), frame.buffer_mut()); + }) + .unwrap(); + assert_eq!(t.cursor_pos(area), Some(expected_cursor)); + snapshots.push(format!( + "{label}\ntext: {:?}\ncursor: {expected_cursor:?}\n{}", + t.text(), + terminal.backend() + )); + } + assert_eq!(t.text(), "abad "); + insta::assert_snapshot!("typing_after_eol_spaces", snapshots.join("\n\n")); +} + +#[test] +fn explicit_indentation_and_trailing_spaces_do_not_hang() { + for (text, expected_rows) in [ + ("abad\n a", vec!["abad", "", " a"]), + ("abad \n a", vec!["abad", " ", " a"]), + (" a", vec![" ", "a"]), + ("abad ", vec!["abad", " "]), + ("abad\u{2003}", vec!["abad", "\u{2003}"]), + ("abad\u{3000}\n a", vec!["abad", "\u{3000}", " a"]), + ] { + assert_eq!( + wrapped_rows(text, /*width*/ 4), + expected_rows, + "text={text:?}" + ); + } +} + +#[test] +fn nonbreaking_spaces_never_hang() { + for space in ['\u{a0}', '\u{2007}', '\u{202f}'] { + let text = format!("abcd{space} x"); + let last = format!("{space} x"); + assert_eq!(wrapped_rows(&text, /*width*/ 4), ["abcd", last.as_str()]); + } +} + +#[test] +fn prose_wraps_like_the_queued_input_preview() { + for text in [ + "new access tokens", + "aaaa next", + "a foo-barbaz", + "some ordinary prose", + ] { + for width in 3..=16 { + let composer = wrapped_rows(text, width) + .into_iter() + .map(|row| row.trim_end_matches(' ')) + .filter(|row| !row.is_empty()) + .collect::>(); + let preview = + adaptive_wrap_lines([Line::from(text)], RtOptions::new(usize::from(width))) + .into_iter() + .map(|line| line.to_string()) + .collect::>(); + assert_eq!(composer, preview, "text={text:?}, width={width}"); + } + } +} + +#[test] +fn wraps_maximum_length_unbroken_word_in_one_pass() { + let text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); + let width = 80; + let ranges = wrapped_lines(&text, width); + + assert_eq!( + ranges.len(), + MAX_USER_INPUT_TEXT_CHARS.div_ceil(usize::from(width)) + ); + assert_eq!(ranges.first(), Some(&(0..usize::from(width) + 1))); + assert_eq!( + ranges.last().map(|range| range.end), + Some(MAX_USER_INPUT_TEXT_CHARS + 1) + ); +} + +#[test] +fn ascii_wrapped_rows_fit_and_preserve_cursor_positions() { + for len in 0_u32..=7 { + for mut encoded in 0..4_usize.pow(len) { + let mut text = String::with_capacity(len as usize); + for _ in 0..len { + text.push([' ', 'a', 'b', '-'][encoded % 4]); + encoded /= 4; + } + + for width in 1_u16..=5 { + let mut t = TextArea::new(); + t.insert_str(&text); + let ranges = t.wrapped_lines(width).to_vec(); + let mut end = 0; + for range in &ranges { + assert_eq!(range.start, end, "text={text:?}, width={width}"); + end = range.end - 1; + let row = &text[range.start..end]; + assert!( + display_width(row.trim_end_matches(' ')) <= usize::from(width), + "text={text:?}, width={width}, row={row:?}" + ); + } + assert_eq!(end, text.len(), "text={text:?}, width={width}"); + + let area = Rect::new(0, 0, width, text.len() as u16 + 1); + let mut previous: Option<(u16, u16)> = None; + for cursor in 0..=text.len() { + t.set_cursor(cursor); + let position = t.cursor_pos(area).unwrap(); + if let Some(previous) = previous { + assert!( + position.1 > previous.1 + || (position.1 == previous.1 && position.0 > previous.0) + || (position == previous && text.as_bytes()[cursor - 1] == b' '), + "text={text:?}, width={width}, ranges={ranges:?}, cursor={cursor}, previous={previous:?}, position={position:?}" + ); + } + previous = Some(position); + } + } + } + } +} diff --git a/codex-rs/tui/src/exec_cell/snapshots/codex_tui__exec_cell__render__tests__truncated_live_output_preview_and_transcript.snap b/codex-rs/tui/src/exec_cell/snapshots/codex_tui__exec_cell__render__tests__truncated_live_output_preview_and_transcript.snap new file mode 100644 index 0000000000000000000000000000000000000000..eb47a610cc5d54ede53f8e5faee8dd5fb27578b4 --- /dev/null +++ b/codex-rs/tui/src/exec_cell/snapshots/codex_tui__exec_cell__render__tests__truncated_live_output_preview_and_transcript.snap @@ -0,0 +1,46 @@ +--- +source: tui/src/exec_cell/render.rs +expression: "(preview, transcript)" +--- +( + [ + Line::from_iter([ + Span::from("•").dim(), + Span::from(" "), + Span::from("Running").bold(), + Span::from(" "), + Span::from("echo").fg(Color::Rgb(137, 180, 250)), + Span::from(" output").fg(Color::Rgb(205, 214, 244)), + ]), + Line::from_iter([ + Span::from(" └ ").dim(), + Span::from("head error that wraps onto the next row").red().dim(), + Span::from("... 1189804 bytes").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(), + ]), + Line::from_iter([ + Span::from(" "), + Span::from("omitted ...").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(), + Span::from("tail output that also wraps").green().bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(), + ]), + ], + [ + Line::from_iter([ + Span::from("$ ").magenta(), + Span::from("echo").fg(Color::Rgb(137, 180, 250)), + Span::from(" output").fg(Color::Rgb(205, 214, 244)), + ]), + Line::from_iter([ + Span::from("head error that wraps onto the next row").red(), + Span::from("... 1189804 bytes").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).not_bold().not_dim().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(), + ]), + Line::from_iter([ + Span::from("omitted ...").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).not_bold().not_dim().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(), + Span::from("tail output that also wraps").green().bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(), + ]), + Line::from_iter([ + Span::from("✗").red().bold(), + Span::from(" (1)"), + Span::from(" • 0ms").dim(), + ]), + ], +) diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__flow__tests__external_agent_config_migration_messages.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__flow__tests__external_agent_config_migration_messages.snap new file mode 100644 index 0000000000000000000000000000000000000000..59a70e4e0e0e8822cc90d9e6914f03c6be1541d2 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__flow__tests__external_agent_config_migration_messages.snap @@ -0,0 +1,43 @@ +--- +source: tui/src/external_agent_config_migration/flow_tests.rs +expression: messages +--- +• Import started. You can keep working while it finishes. + Imported setup will apply to new chats. + Importing: + Settings: 1 + Memory: 2 — project-a, project-b + Skills: 4 — triage, release-notes, risk-check, +1 more + MCP servers: 2 — docs, issues + Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes + Plugins: 2 — formatter, reviewer +• Import started. You can keep working while it finishes. + Imported setup will apply to new chats. + Importing: + Settings: 1 + Memory: 2 — project-a, project-b + Skills: 4 — triage, release-notes, risk-check, +1 more + MCP servers: 2 — docs, issues + Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes + Plugins: 2 — formatter, reviewer + 1 additional item remains. After it finishes, run /import again to review it. +• Import started. You can keep working while it finishes. + Imported setup will apply to new chats. + Importing: + Settings: 1 + Memory: 2 — project-a, project-b + Skills: 4 — triage, release-notes, risk-check, +1 more + MCP servers: 2 — docs, issues + Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes + Plugins: 2 — formatter, reviewer + 2 additional items remain. After it finishes, run /import again to review them. +• Import finished: 4 imported, 1 failed. + Results by type: + Settings: 1 imported, 0 failed + Memory: 2 imported, 0 failed + Plugins: 1 imported, 1 failed + Run /import again to check for additional items. +No compatible setup was found to import. +Import from other apps is unavailable in remote sessions. Start Codex locally and run /import. +Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import. +A previous external agent import is still running. Wait for it to finish before importing again. diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__source__tests__external_agent_config_source_prompt.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__source__tests__external_agent_config_source_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..106cad41f21bd6ada76ce6ce389d1d7597c31dab --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__source__tests__external_agent_config_source_prompt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/external_agent_config_migration/source_tests.rs +expression: terminal.backend() +--- + +Choose an import source + + Select the app whose setup you want to import. + +› 1. Claude Code + 2. Cursor + + Press enter to continue diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize.snap new file mode 100644 index 0000000000000000000000000000000000000000..08ff99ff9fb2bf0b756605dfb26bd77d4bc8228d --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize.snap @@ -0,0 +1,32 @@ +--- +source: tui/src/external_agent_config_migration/mod.rs +expression: rendered +--- + + > Choose what to import + Choose items to import. + Codex may add files to your current project folder. + Your existing setup will not be changed. + Home + › [x] Settings + Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… + [x] Memory + Import memory files from /Users/alex/.claude/projects to /Users/alex/… + 1 memory: project + [x] Recent chat sessions + Import recent Claude Code sessions + 1 chat session: Investigate migration UX + + Current project: /workspace/project + [x] Plugins + Import enabled plugins from .claude/settings.json (4 marketplaces, 6 … + • acme-tools: deployer, formatter, +1 more + • team-marketplace: asana + • debug: sample + • +1 more marketplaces + [x] Instructions + Import CLAUDE.md to AGENTS.md + + Selected 5 of 5 items. + 1. Review selection + Use ↑/↓ to move, space to toggle, b to go back diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action.snap new file mode 100644 index 0000000000000000000000000000000000000000..74655a4db8973b3228f27f563fc3349e2e7100ce --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/external_agent_config_migration/mod.rs +expression: rendered +--- + + > Choose what to import + Choose items to import. + Codex may add files to your current project folder. + Your existing setup will not be changed. + Home + [x] Settings + Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… + [x] Recent chat sessions + Import recent Claude Code sessions + 1 chat session: Investigate migration UX + + Current project: /workspace/project + [x] Plugins + Import enabled plugins from .claude/settings.json (4 marketplaces, 6 … + • acme-tools: deployer, formatter, +1 more + • team-marketplace: asana + • debug: sample + • +1 more marketplaces + [x] Instructions + Import CLAUDE.md to AGENTS.md + + Selected 4 of 4 items. + › 1. Review selection + Press enter to continue, ↑/↓ to move, b to go back diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action_windows.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action_windows.snap new file mode 100644 index 0000000000000000000000000000000000000000..128059f2001e95ca0b907acd995220be1fba5771 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_action_windows.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/external_agent_config_migration/mod.rs +expression: rendered +--- + + > Choose what to import + Choose items to import. + Codex may add files to your current project folder. + Your existing setup will not be changed. + Home + [x] Settings + Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… + [x] Recent chat sessions + Import recent Claude Code sessions + 1 chat session: Investigate migration UX + + Current project: C:\workspace\project + [x] Plugins + Import enabled plugins from .claude/settings.json (4 marketplaces, 6 … + • acme-tools: deployer, formatter, +1 more + • team-marketplace: asana + • debug: sample + • +1 more marketplaces + [x] Instructions + Import CLAUDE.md to AGENTS.md + + Selected 4 of 4 items. + › 1. Review selection + Press enter to continue, ↑/↓ to move, b to go back diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_windows.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_windows.snap new file mode 100644 index 0000000000000000000000000000000000000000..69475cc7f39ae143adf8af300dc2c6a940ae2212 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_customize_windows.snap @@ -0,0 +1,32 @@ +--- +source: tui/src/external_agent_config_migration/mod.rs +expression: rendered +--- + + > Choose what to import + Choose items to import. + Codex may add files to your current project folder. + Your existing setup will not be changed. + Home + › [x] Settings + Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf… + [x] Memory + Import memory files from /Users/alex/.claude/projects to /Users/alex/… + 1 memory: project + [x] Recent chat sessions + Import recent Claude Code sessions + 1 chat session: Investigate migration UX + + Current project: C:\workspace\project + [x] Plugins + Import enabled plugins from .claude/settings.json (4 marketplaces, 6 … + • acme-tools: deployer, formatter, +1 more + • team-marketplace: asana + • debug: sample + • +1 more marketplaces + [x] Instructions + Import CLAUDE.md to AGENTS.md + + Selected 5 of 5 items. + 1. Review selection + Use ↑/↓ to move, space to toggle, b to go back diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..8998de2ecd7a2321348c128035ef310d2e7d3921 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/external_agent_config_migration/mod.rs +expression: rendered +--- + + > Import setup + Bring over supported setup from another coding agent. + Codex may add files to your current project folder. + Your existing setup will not be changed. + [x] Tools & setup + Settings, instructions, integrations, agents, commands, and skills + Importing: Settings 1, Memory 1 + [x] Current project + Add Codex files alongside your existing project files + Importing: Plugins 6, Instructions 1 + [x] Chat sessions (1) + Last 30 days of chats + Importing: Chat sessions 1 + + Selected 5 of 5 items. + › 1. Import selected + 2. Customize selection + 3. Cancel + Use ↑/↓ to move, enter to select, c to customize diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap new file mode 100644 index 0000000000000000000000000000000000000000..8998de2ecd7a2321348c128035ef310d2e7d3921 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap @@ -0,0 +1,24 @@ +--- +source: tui/src/external_agent_config_migration/mod.rs +expression: rendered +--- + + > Import setup + Bring over supported setup from another coding agent. + Codex may add files to your current project folder. + Your existing setup will not be changed. + [x] Tools & setup + Settings, instructions, integrations, agents, commands, and skills + Importing: Settings 1, Memory 1 + [x] Current project + Add Codex files alongside your existing project files + Importing: Plugins 6, Instructions 1 + [x] Chat sessions (1) + Last 30 days of chats + Importing: Chat sessions 1 + + Selected 5 of 5 items. + › 1. Import selected + 2. Customize selection + 3. Cancel + Use ↑/↓ to move, enter to select, c to customize diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_secondary_source_customize.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_secondary_source_customize.snap new file mode 100644 index 0000000000000000000000000000000000000000..47f673802fb0a2d621718abb7c509bf5653f921a --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_secondary_source_customize.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/external_agent_config_migration/mod.rs +expression: rendered +--- + + > Choose what to import + Choose items to import. + Codex may add files to your current project folder. + Your existing setup will not be changed. + Home + › [x] Settings + Import /Users/alex/.cursor/cli-config.json into /Users/alex/.codex/co… + + Selected 1 of 1 item. + 1. Review selection + Use ↑/↓ to move, space to toggle, b to go back diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_active.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_active.snap new file mode 100644 index 0000000000000000000000000000000000000000..5ea2b1a6402b509bdb567550e5eac45f48dee1c7 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_active.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell/computer_activity_tests.rs +expression: "render(&cell, 80)" +--- +• Using computer · 2 actions + └ Load the terminal diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_expanded.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_expanded.snap new file mode 100644 index 0000000000000000000000000000000000000000..e28eead005acb3d3577980fcebec825131c682e7 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_expanded.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/history_cell/computer_activity_tests.rs +expression: transcript +--- +• Called cua_repl.js({"title":"Open browser","code":"await cua.getState()"}) + └ Error: No browser is available + ## Computer Use + Full tool documentation +• Called cua_repl.js({"title":"Connect to Chrome","code":"await cua.getState()"}) + └ connected +• Called cua_repl.js({"title":"Reproduce the clear-command bug","code":"await cua.getState()"}) + └ reproduced +• Called cua_repl.js({"title":"Capture the reproduced bug","code":"await cua.getState()"}) + └ screenshot metadata + +• Called cua_repl.js({"title":"Check the final page","code":"await cua.getState()"}) + └ checked diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_failed_result.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_failed_result.snap new file mode 100644 index 0000000000000000000000000000000000000000..63a71f5277dc9fb42d2a25fb265f84c2d891b408 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_failed_result.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell/computer_activity_tests.rs +expression: "render(&cell, 90)" +--- +• Used computer · 3 actions · 1 failed + ├ Inspect page + ├ Reload page + └ Failed: Inspect refreshed page — Browser state changed diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_interrupted.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_interrupted.snap new file mode 100644 index 0000000000000000000000000000000000000000..4d9393b283faa2937ab2070306e899e28263ea61 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_interrupted.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell/computer_activity_tests.rs +expression: "render(&cell, 80)" +--- +• Used computer · 2 actions · 1 failed + ├ Opened Chrome + └ Failed: Load the terminal — interrupted diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_mixed.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_mixed.snap new file mode 100644 index 0000000000000000000000000000000000000000..85a82bf55a3397b4eb2913ae7c2657e7598498b0 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_mixed.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell/computer_activity_tests.rs +expression: "render(&cell, 90)" +--- +• Used computer · 5 actions · 1 failed + ├ Failed: Open browser — No browser is available + ├ Captured screenshot · Capture the reproduced bug + └ 3 more · ctrl+t diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_narrow.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_narrow.snap new file mode 100644 index 0000000000000000000000000000000000000000..71463deb939059327368fa48d3ef5a5c0625950a --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_narrow.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell/computer_activity_tests.rs +expression: "render(&cell, 24)" +--- +• Used computer · 5 + actions · 1 failed + ├ Failed: No browser … + ├ Captured screenshot… + └ 3 more · ctrl+t diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_success.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_success.snap new file mode 100644 index 0000000000000000000000000000000000000000..2cd0ea94e6bf30b5c12f88c78bd2b602320642b8 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_success.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell/computer_activity_tests.rs +expression: "render(&cell, 80)" +--- +• Used computer · 2 actions + ├ Inspect page + └ Reload page diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_unicode.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_unicode.snap new file mode 100644 index 0000000000000000000000000000000000000000..47504740bb32684606d1f7d63b9f72dab7645812 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__computer_activity__tests__computer_activity_unicode.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell/computer_activity_tests.rs +expression: "render(&cell, 24)" +--- +• Used computer · 1 + action · 1 failed + └ Failed: diagnostic … diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_connection_states.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_connection_states.snap new file mode 100644 index 0000000000000000000000000000000000000000..345ecd348bfe6a872dd67b0618ca01d5e8671d61 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_connection_states.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/history_cell/mcp_tests.rs +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • auth: authentication required (0 tools) + • cancelled: cancelled (0 tools) + • connected-empty: connected (0 tools) + • deferred: not started (0 tools) + • disabled: disabled (0 tools) + • failed: failed (0 tools) + • starting: starting (0 tools) + • unknown: unknown (0 tools) + + Use /mcp verbose for tools and resources. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_older_app_server_authentication.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_older_app_server_authentication.snap new file mode 100644 index 0000000000000000000000000000000000000000..4740015044ce450b122f2f09292885a73360dfed --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__mcp__tests__mcp_inventory_older_app_server_authentication.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/history_cell/mcp_tests.rs +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • legacy-auth: authentication required (0 tools) + • legacy-healthy: unknown (1 tool) + • runtime-disabled: disabled (0 tools) + + Use /mcp verbose for tools and resources. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__messages__tests__spoken_user_prompt.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__messages__tests__spoken_user_prompt.snap new file mode 100644 index 0000000000000000000000000000000000000000..5e4c5f320f6fd8587191ac96a5461cd8c2cb5ed3 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__messages__tests__spoken_user_prompt.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/history_cell/messages_tests.rs +assertion_line: 107 +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 40, height: 3 }, + content: [ + " ", + "› hello from voice ", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Red, bg: Reset, underline: Reset, modifier: BOLD, + x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..2077da659f84d5d0fc94ccae9bd06d26907eddd6 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Calling search.find_docs({"query":"ratatui styling","limit":3}) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap new file mode 100644 index 0000000000000000000000000000000000000000..2044d41a1a7408b73031428804d7b6609420ca01 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Explored + └ Read auth.rs, shimmer.rs diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap new file mode 100644 index 0000000000000000000000000000000000000000..fcfa31eedec6b810ab59be7152a62867d3fff697 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Explored + └ Search shimmer_spans + Read shimmer.rs, status_indicator_widget.rs diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap new file mode 100644 index 0000000000000000000000000000000000000000..533a489ee6f00ccdbc9bd6667acc036923da9a0e --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Explored + └ Search shimmer_spans + Read shimmer.rs + Read status_indicator_widget.rs diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..bb0b826bbc43d4479c86a8a8b95b5442d56368aa --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Called search.find_docs({"query":"ratatui styling","limit":3}) + └ Error: network timeout diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..0ee965d82fc51969a4c5664aaecb5231b322ff2f --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Called metrics.summary({"metric":"trace.latency","window":"15m"}) + └ Latency summary: p50=120ms, p95=480ms. + No anomalies detected. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..62cc89b2f37c9b47a2d1afff10d060a94ee57a61 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Called + └ search.find_docs({"query":"ratatui + styling","limit":3}) + Found styling guidance in styles.md and + additional notes in CONTRIBUTING.md. + link: file:///docs/styles.md diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..cd5eb9dc0c9509832e1e9a4d56f5dd20bada535d --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Called search.find_docs({"query":"ratatui styling","limit":3}) + └ Found styling guidance in styles.md diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..ed2e0c236df583bc4e248b68877fe6ede8c9bcff --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Called + └ metrics.get_nearby_metric({"query":" + very_long_query_that_needs_wrapp + ing_to_display_properly_in_the_h + istory","limit":1}) + Line one of the response, which is + quite long and needs wrapping. + Line two continues the response with + more detail. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__connected_server_version_notice_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__connected_server_version_notice_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..fc38c4a8e139dd5fb11ea68ce538dcdfb0b22a78 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__connected_server_version_notice_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "render_lines(&cell.display_lines(100)).join(\"\\n\")" +--- +⚠ A background Codex service is running v0.152.1, older than your Codex CLI v0.153.0. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_astra_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_astra_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..8c5cfba1c03af2cd15be72e0ce33a584983a6929 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_astra_snapshot.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +ⓘ This content can’t be shown + Daybreak isn’t available for Astra. Some cybersecurity requests may still be + limited. + Learn more: https://help.openai.com/en/articles/20001326 diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_limited_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_limited_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..2b946b26e366284d4ac707c09ee88632f3286a52 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_limited_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +ⓘ This content can’t be shown + We take extra care with some cybersecurity requests. + Learn more: https://help.openai.com/en/articles/20001326 diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_narrow_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_narrow_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..7c5ac526b44420e35f5f82a6e7dc4b9670296f10 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_narrow_snapshot.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/history_cell/tests.rs +assertion_line: 821 +expression: rendered +--- +ⓘ This content can’t be shown + We take extra care with some + cybersecurity requests. If + you’re doing authorized security + work, apply for Daybreak to get + broader access. + Learn more: https:// + help.openai.com/en/ + articles/20001326 + Apply for Daybreak: https:// + openai.com/form/enterprise- + trusted-access-for-cyber/ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..05091f97fbbed2337a3bbe1e5cf87eeb55afb9eb --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +assertion_line: 774 +expression: rendered +--- +ⓘ This content can’t be shown + We take extra care with some cybersecurity requests. If you’re doing + authorized security work, apply for Daybreak to get broader access. + Learn more: https://help.openai.com/en/articles/20001326 + Apply for Daybreak: https://openai.com/form/enterprise-trusted-access-for- + cyber/ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..3652ad442821e15f98a27ccfc4f8ff11d5592b9b --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +■ Amazon Bedrock rejected the request because its AWS signature has expired. Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or unset it, then restart Codex, url: https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_oversized_input_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_oversized_input_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..cf4b4134a67fb0265410fd196e49463a72ad85c7 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_oversized_input_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +■ Message exceeds the maximum length of 1048576 characters (1048577 provided). diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__local_daemon_version_notice_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__local_daemon_version_notice_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..e3ade9c703dc0dda221adf001f2cc99e3fe42d75 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__local_daemon_version_notice_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "render_lines(&cell.display_lines(100)).join(\"\\n\")" +--- +⚠ A background Codex service is running v0.152.1, older than your Codex CLI v0.153.0. + Use /daemon to manage the local background server. + Updating may interrupt active or queued work. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_inventory_loading_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_inventory_loading_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..c1b607e63c504fa5a6821003b331a223ffb88eab --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_inventory_loading_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 3477 +expression: rendered +--- +• Loading MCP inventory… diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap new file mode 100644 index 0000000000000000000000000000000000000000..acd905ca78d9fdd78b8b85b64a96094370d12b77 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_status_only_servers.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • plugin_docs: unknown (1 tool) + + Use /mcp verbose for tools and resources. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_verbose_inventory.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_verbose_inventory.snap new file mode 100644 index 0000000000000000000000000000000000000000..c02ed12eec442545137a9641906fdf6ae3f1ece8 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_verbose_inventory.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • plugin_docs: unknown (1 tool) + • Auth: Unsupported + • Tools: lookup + • Resources: Docs (file:///docs) + • Resource templates: Doc Template (file:///docs/{id}) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap new file mode 100644 index 0000000000000000000000000000000000000000..bb30096b68210b599a2e65fdfc0328227e72e645 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 3310 +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • some-server + • Status: enabled + • Auth: Unsupported + • Command: docs-server --stdio + • Tools: lookup + • Resources: (none) + • Resource templates: (none) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap new file mode 100644 index 0000000000000000000000000000000000000000..329cd232d8571de59a54e89201a60bee06b6fc8e --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap @@ -0,0 +1,26 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • docs + • Status: enabled + • Auth: Unsupported + • Command: docs-server + • Env: TOKEN=*****, APP_TOKEN=***** + • Tools: list + • Resources: (none) + • Resource templates: (none) + + • http + • Status: enabled + • Auth: Unsupported + • URL: https://example.com/mcp + • HTTP headers: Authorization=***** + • Env HTTP headers: X-API-Key=API_KEY_ENV + • Tools: ping + • Resources: (none) + • Resource templates: (none) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap new file mode 100644 index 0000000000000000000000000000000000000000..b5d4fac168d0aca683309a98e958bd207e733466 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran first_token_is_long_en + │ ough_to_wrap + │ second_token_is_also_lon + │ … +1 lines + └ (no output) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap new file mode 100644 index 0000000000000000000000000000000000000000..10d87ad9dc2810f195daae635f3ff0b2f75a1918 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran echo one + │ echo two + └ (no output) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap new file mode 100644 index 0000000000000000000000000000000000000000..99672e3e00f28131fef8c3924994797ad13f5796 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran set -o pipefail + │ cargo test -p codex-tui + │ --quiet + └ (no output) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..3c5a1526b9570bd672f787d184843f36bceaa36f --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Updated Plan + └ I’ll update Grafana call + error handling by adding + retries and clearer messages + when the backend is + unreachable. + ✔ Investigate existing error + paths and logging around + HTTP timeouts + □ Harden Grafana client + error handling with retry/ + backoff and user‑friendly + messages + □ Add tests for transient + failure scenarios and + surfacing to the UI diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..e3ac8844f81d37250b1ec52788a1e564fa63f7a8 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Updated Plan + └ □ Define error taxonomy + □ Implement mapping to user messages diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..a9dd9cac784bd0993eb09bc518071a1e253f0ae2 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run pnpm add -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__portable_image_raw_output.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__portable_image_raw_output.snap new file mode 100644 index 0000000000000000000000000000000000000000..c6ad6c2772069d6cde56b4540a720d541d05a147 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__portable_image_raw_output.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "render_lines(&cell.raw_lines()).join(\"\\n\")" +--- +[Image #1] describe inline image diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..e13dcf0a1ef3ef9e2e4a42336607da9518f91a15 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_chunk_leading_whitespace_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • just fix + ↳ indented first + more indented diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_empty_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_empty_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..a638aca723d905c89807658bc72e54ce44f3246a --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_empty_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • No background terminals running. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_halfwidth_sound_marks_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_halfwidth_sound_marks_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..a39929e48fb4b4eee0d2562efa1d863212e53044 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_halfwidth_sound_marks_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +/ps + +Background terminals + + • echo ガパガパ [...] + ↳ output ガパ [...] diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..2bd4bf9fb970e28f7e87b97bd555681c5a523d40 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • rg "foo" src --glob '**/*. [...] + ↳ searching... diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_many_sessions_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_many_sessions_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..d0138b2744321ea4109e86663bb341783cbc8349 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_many_sessions_snapshot.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • command 0 + • command 1 + • command 2 + • command 3 + • command 4 + • command 5 + • command 6 + • command 7 + • command 8 + • command 9 + • command 10 + • command 11 + • command 12 + • command 13 + • command 14 + • command 15 + • ... and 4 more running diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..0e31eddfa0e7709f134c18b29e3e56580ab5e8c0 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • echo hello [...] + ↳ hello + done + • rg "foo" src + ↳ src/main.rs:12:foo diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..0c7b5ee8401dd8dd7ab6a7612ce840c4bc41138c --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran echo + │ this_is_a_very_long_si + │ ngle_token_that_will_w + │ … +2 lines + └ error: first line on + stderr + error: second line on + stderr diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap new file mode 100644 index 0000000000000000000000000000000000000000..e2c378fff6464b47051e1fc7bd25bad9af909f5b --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap @@ -0,0 +1,58 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +rich before: + +› Please format this + for copying + +• - first item + - second item + + Col Value + ━━━━━━ ━━━━━━━ + code x = 1 + + copy me +• Called + └ workspace.inspect({"path":"README.md + "}) + structured output + second line + +raw on: +Please format this +for copying +- first item +- second item + +| Col | Value | +| --- | --- | +| code | `x = 1` | + +```text +copy me +``` +Called workspace.inspect({"path":"README.md"}) +structured output +second line + +rich after: + +› Please format this + for copying + +• - first item + - second item + + Col Value + ━━━━━━ ━━━━━━━ + code x = 1 + + copy me +• Called + └ workspace.inspect({"path":"README.md + "}) + structured output + second line diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__safety_access_block_event_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__safety_access_block_event_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..c75c15ede744ed0f58f30e36c8f5b4f7b113b9a7 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__safety_access_block_event_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +ⓘ This content can't be shown + We take extra caution with requests involving biological research and + applications that could pose safety risks. Eligible researchers can apply + for Trusted Access. + Trusted Access: https://chatgpt.com/r/b749fb02595e04c3007a54375f3f4374 + Learn more: https://help.openai.com/en/articles/20001326 diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_clamps_to_narrow_width.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_clamps_to_narrow_width.snap new file mode 100644 index 0000000000000000000000000000000000000000..2baf486e51ef70b31759b8c9ad6923dc664dabcf --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_clamps_to_narrow_width.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "render_lines(&lines).join(\"\\n\")" +--- +╭──────────────────────────────────────────╮ +│ >_ OpenAI Codex (vtest) │ +│ │ +│ model: gpt-5.6-sol xhigh fast … │ +│ directory: project │ +│ permissions: YOLO mode │ +╰──────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_halfwidth_directory.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_halfwidth_directory.snap new file mode 100644 index 0000000000000000000000000000000000000000..51d36524d861bac58e3bcb4aafb4f84dc8902095 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_halfwidth_directory.snap @@ -0,0 +1,26 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 42, height: 6 }, + content: [ + "╭────────────────────────────────────────╮", + "│ >_ OpenAI Codex (vtest) │", + "│ │", + "│ model: gpt-5 /model to change │", + "│ directory: …パガパガパガパガパ-project │", // hidden by multi-width symbols: [(15, " "), (17, " "), (19, " "), (21, " "), (23, " "), (25, " "), (27, " "), (29, " "), (31, " ")] + "╰────────────────────────────────────────╯", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 18, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 21, y: 3, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 13, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 40, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + ] +} diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_halfwidth_sound_marks.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_halfwidth_sound_marks.snap new file mode 100644 index 0000000000000000000000000000000000000000..0fb17271fa014079dff9dfe20b8e4ce76c322291 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_halfwidth_sound_marks.snap @@ -0,0 +1,37 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 80, height: 6 }, + content: [ + "╭───────────────────────────────────────────╮ ", + "│ >_ OpenAI Codex (vtest) │ ", + "│ │ ", + "│ model: gpt-5-ガ-パ /model to change │ ", // hidden by multi-width symbols: [(20, " "), (23, " ")] + "│ directory: project │ ", + "╰───────────────────────────────────────────╯ ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 27, y: 3, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, + x: 33, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 13, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 20, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_indicates_yolo_mode.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_indicates_yolo_mode.snap new file mode 100644 index 0000000000000000000000000000000000000000..04f64584f62dd05828a700e8c72a5a79edb3bf6f --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_header_indicates_yolo_mode.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +╭───────────────────────────────────────╮ +│ >_ OpenAI Codex (vtest) │ +│ │ +│ model: gpt-5 /model to change │ +│ directory: /tmp/project │ +│ permissions: YOLO mode │ +╰───────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_info_availability_nux_tooltip_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_info_availability_nux_tooltip_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..ad0fc4be1c1b08518ad844b50b6cceb428b7f530 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__session_info_availability_nux_tooltip_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +╭─────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ model: gpt-5 /model to change │ +│ directory: /tmp/project │ +╰─────────────────────────────────────╯ + + Tip: Model just became available diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap new file mode 100644 index 0000000000000000000000000000000000000000..23827df922d501a4dc8f697db081da8fae2550b3 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran echo ok + └ (no output) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_over_highlight_limit_uses_plain_text_fallback.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_over_highlight_limit_uses_plain_text_fallback.snap new file mode 100644 index 0000000000000000000000000000000000000000..5c1d0f5bc79182eed43e8e1af313394a9f32e2b8 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_over_highlight_limit_uses_plain_text_fallback.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +• Ran AAAAAAAAAAAAAAAAAA + │ AAAAAAAAAAAAAAAAAA + │ AAAAAAAAAAAAAAAAAA + │ … +225 lines + └ (no output) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap new file mode 100644 index 0000000000000000000000000000000000000000..244fd1d7e4cf38af7ee15760f99c7c0ab2d68533 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran a_very_long_token_ + │ without_spaces_to_ + │ force_wrapping + └ (no output) diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..c8f352dbb49ceafe78df86e841e95824c4e6f469 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run sh -c 'curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh' to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..fb15a05775d4063e2780ba29d0ae15e9ad5297c8 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run powershell -ExecutionPolicy Bypass -c '$env:CODEX_NON_INTERACTIVE=1; irm │ +│ https://chatgpt.com/codex/install.ps1 | iex' to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..5ac16217c602c352f14c59a94c490e68f6b463cf --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Ran seq 1 10 1>&2 && false + └ 1 + 2 + … +6 lines (ctrl + t to view transcript) + 9 + 10 diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__streamed_agent_list_paragraph_preserves_item_indent_when_wrapped.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__streamed_agent_list_paragraph_preserves_item_indent_when_wrapped.snap new file mode 100644 index 0000000000000000000000000000000000000000..2fefd57ef92445f35e6b15e5fa08083ee369d8c2 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__streamed_agent_list_paragraph_preserves_item_indent_when_wrapped.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "lines.join(\"\\n\")" +--- +• 1. Correctness issue: server tool-search completions are + rejected. + + In next_prompt_suggestion.rs, ToolSearchCall records its + call id, but a paired output is ignored and suppresses + suggestions. diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_numbers_multiple_remote_images.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_numbers_multiple_remote_images.snap new file mode 100644 index 0000000000000000000000000000000000000000..076df8fffb268ab10b9843443b95583c5c64af92 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_numbers_multiple_remote_images.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- + + [Image #1] + [Image #2] + +› describe both diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_renders_remote_image_urls.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_renders_remote_image_urls.snap new file mode 100644 index 0000000000000000000000000000000000000000..923ee953ab91a330e2709b1527aef28008e92c03 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_renders_remote_image_urls.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- + + [Image #1] + +› describe these diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..beaad7984de273981278bd53edfca0e7356a202a --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +› _count_ro + ws diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_long_urls_inside_the_message_gutter.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_long_urls_inside_the_message_gutter.snap new file mode 100644 index 0000000000000000000000000000000000000000..2d8a2fac39bcfa25f3e8f04d7fd24a07056f1d89 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_long_urls_inside_the_message_gutter.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "render_lines(&cell.display_lines(width)).join(\"\\n\")" +--- + +› Skip tests. + + I just reprocessed + https://example.test/forwarded/threads/10930?page=1&search=&f + ilter=all&queue=customer_support_unprocessed&sort=latest_desc + &forwardedScope=all + can you check where we are with it? + + [Image #1] diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__vite_plus_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__vite_plus_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..1a2df82a9dd866436660629a9fede7e1e8894899 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__vite_plus_update_available_history_cell_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run vp install -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..2a4e8b3abb15f329cbb63235d2447efc27a03f23 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Searched the web for example search query with several generic + words to exercise wrapping diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_transcript_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_transcript_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..2a4e8b3abb15f329cbb63235d2447efc27a03f23 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_transcript_snapshot.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Searched the web for example search query with several generic + words to exercise wrapping diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_without_detail_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_without_detail_snapshot.snap new file mode 100644 index 0000000000000000000000000000000000000000..ffda6cb13feb3fa892b4baf2fab2102be25b7b94 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_without_detail_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Searched the web diff --git a/codex-rs/tui/src/render/snapshots/codex_tui__render__highlight__tests__ansi_family_foreground_palette.snap b/codex-rs/tui/src/render/snapshots/codex_tui__render__highlight__tests__ansi_family_foreground_palette.snap new file mode 100644 index 0000000000000000000000000000000000000000..79e130916c0d235dabdd6c2edd3fca660db83e66 --- /dev/null +++ b/codex-rs/tui/src/render/snapshots/codex_tui__render__highlight__tests__ansi_family_foreground_palette.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/render/highlight.rs +expression: out +--- +ansi: + Blue + Green + Magenta + Yellow +base16: + Blue + Gray + Green + Indexed(9) + Magenta +base16-256: + Blue + Gray + Green + Indexed(16) + Magenta diff --git a/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_1.snap b/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_1.snap new file mode 100644 index 0000000000000000000000000000000000000000..e2675bf7671a2e8099ac27eb4829ce1b55b1952e --- /dev/null +++ b/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_1.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/tui/scrollback_tests.rs +expression: screen.contents() +--- +history-2 +history-3 +history-4 + +composer-1 +composer-2 diff --git a/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_3.snap b/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_3.snap new file mode 100644 index 0000000000000000000000000000000000000000..2b4a26782ca6ceb029fd0bd14fb1fdb757c385bc --- /dev/null +++ b/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_3.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/tui/scrollback_tests.rs +expression: screen.contents() +--- +history-4 + + + +composer-1 +composer-2 diff --git a/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_4.snap b/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_4.snap new file mode 100644 index 0000000000000000000000000000000000000000..3f85e02e23abfb11e7f409425aa9941e6c2f9efc --- /dev/null +++ b/codex-rs/tui/src/tui/snapshots/codex_tui__tui__scrollback__tests__standard_viewport_growth_4.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/tui/scrollback_tests.rs +expression: screen.contents() +--- + + + + +composer-1 +composer-2 diff --git a/codex-rs/tui/tests/fixtures/oss-story.jsonl b/codex-rs/tui/tests/fixtures/oss-story.jsonl new file mode 100644 index 0000000000000000000000000000000000000000..a62182f4879c11feff7d34b5d4e2d43d109eedcc --- /dev/null +++ b/codex-rs/tui/tests/fixtures/oss-story.jsonl @@ -0,0 +1,8041 @@ +{"ts":"2025-08-10T03:12:26.500Z","dir":"meta","kind":"session_start","cwd":"/Users/easong/code/codex/codex-rs","model":"gpt-oss:20b","model_provider_id":"oss","model_provider_name":"gpt-oss"} +{"ts":"2025-08-10T03:12:26.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:26.502Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"} +{"ts":"2025-08-10T03:12:26.502Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:26.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"8f7c4ac2-6141-42da-b4d5-7032a8e8df3b","model":"gpt-oss:20b"}}} +{"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"insert_history","lines":9} +{"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:26.522Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:28.561Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('h'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:28.563Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:28.564Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:28.650Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('h'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:28.676Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:28.676Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:28.678Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:28.748Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:28.810Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:28.810Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:28.812Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:28.868Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:28.947Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:28.947Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:28.949Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.007Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:29.102Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:29.103Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.107Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.151Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:29.189Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Enter, modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:12:29.189Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.190Z","dir":"to_tui","kind":"insert_history","lines":3} +{"ts":"2025-08-10T03:12:29.190Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.192Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.191Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"task_started"}}} +{"ts":"2025-08-10T03:12:29.291Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.291Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.291Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.293Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.396Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.398Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.501Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.503Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.606Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.608Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.709Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.812Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.813Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:29.913Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:29.915Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.018Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.020Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.119Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.120Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.219Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.220Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.320Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.321Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.420Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.421Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.523Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.524Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.626Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.627Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.727Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.827Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:30.926Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:30.927Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.028Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.127Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.129Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.231Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.232Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.334Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.335Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.436Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.437Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.536Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.537Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.637Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.638Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.737Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.738Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.837Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.838Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:31.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:31.939Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.039Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.040Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.142Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.144Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.244Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.246Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.345Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.346Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.445Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.446Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.545Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.547Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.650Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.651Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.750Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.752Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.854Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.855Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:32.959Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:32.960Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.063Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.065Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.166Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.167Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.271Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.272Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.378Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.478Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.480Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.585Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.688Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.690Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.792Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.794Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:33.895Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:33.897Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.002Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.107Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.211Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.212Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.312Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.313Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.417Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.418Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.524Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.525Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.629Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.630Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.731Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.732Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:34.937Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:34.938Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.038Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.039Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.143Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.144Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.246Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.247Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.347Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.348Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.452Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.453Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.557Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.558Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.662Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.663Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.763Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.765Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.868Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.870Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:35.970Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:35.972Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.071Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.073Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.174Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.175Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.279Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.280Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.384Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.385Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.489Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.491Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.593Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.595Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.699Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.700Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.804Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.805Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:36.909Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:36.910Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.011Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.012Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.116Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.117Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.221Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.222Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.322Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.324Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.427Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.429Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.533Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.534Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.638Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.639Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.742Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.743Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.842Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.844Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:37.948Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:37.950Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.049Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.050Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.154Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.155Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.259Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.260Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.364Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.365Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.466Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.468Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.571Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.573Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.676Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.678Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.781Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.783Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.887Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.888Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:38.988Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:38.990Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.092Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.094Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.197Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.199Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.300Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.301Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.405Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.406Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.509Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.510Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.609Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.610Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.714Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.716Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.819Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.821Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:39.924Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:39.926Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.027Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.132Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.133Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.236Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.237Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.338Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.339Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.443Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.445Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.545Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.547Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.650Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.652Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.756Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.757Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.859Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.861Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:40.964Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:40.966Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.066Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.067Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.168Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.169Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.273Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.275Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.378Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.379Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.479Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.480Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.584Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.585Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.690Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.794Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.795Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:41.899Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:41.901Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.004Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.006Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.106Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.209Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.210Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.312Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.313Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.417Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.418Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.523Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.626Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.628Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.729Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.730Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.831Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:42.934Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:42.936Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.039Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.041Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.142Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.144Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.247Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.249Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.354Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.457Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.459Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.559Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.560Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.659Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.660Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.762Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.763Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.867Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.869Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:43.972Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:43.974Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.077Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.079Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.182Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.184Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.287Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.289Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.392Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.394Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.497Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.499Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.603Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.604Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.709Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.809Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.810Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:44.911Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:44.913Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.015Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.016Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.120Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.121Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.221Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.223Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.328Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.329Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.429Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.430Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.529Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.530Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.635Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.739Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.742Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.844Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.845Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:45.949Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:45.950Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.054Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.055Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.159Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.161Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.262Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.263Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.367Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.369Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.472Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.474Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.576Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.577Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.680Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.784Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.785Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.887Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.889Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:46.989Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:46.991Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.095Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.096Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.200Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.201Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.305Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.306Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.409Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.410Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.513Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.515Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.618Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.620Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.720Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.722Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.821Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.823Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:47.926Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:47.927Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.031Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.032Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.136Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.137Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.241Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.242Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.342Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.344Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.443Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.444Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.548Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.549Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.653Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.654Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.758Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.759Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.859Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.860Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:48.959Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:48.960Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.064Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.065Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.169Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.170Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.276Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.376Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.377Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.481Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.482Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.586Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.587Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.691Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.692Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.796Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.797Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:49.901Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:49.902Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.004Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.005Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.108Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.208Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.210Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.312Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.313Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.417Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.418Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.523Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.524Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.628Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.629Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.731Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.733Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.921Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"The"}}} +{"ts":"2025-08-10T03:12:50.921Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.921Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:12:50.921Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.921Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.923Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.937Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.947Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" user"}}} +{"ts":"2025-08-10T03:12:50.973Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" says"}}} +{"ts":"2025-08-10T03:12:50.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.992Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:50.999Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":":"}}} +{"ts":"2025-08-10T03:12:50.999Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:50.999Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.000Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.026Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" \""}}} +{"ts":"2025-08-10T03:12:51.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.027Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.044Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.052Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"hello"}}} +{"ts":"2025-08-10T03:12:51.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.053Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.079Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"\"."}}} +{"ts":"2025-08-10T03:12:51.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.080Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.105Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" So"}}} +{"ts":"2025-08-10T03:12:51.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.106Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.131Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" they've"}}} +{"ts":"2025-08-10T03:12:51.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.133Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.143Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.144Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.158Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" greeted"}}} +{"ts":"2025-08-10T03:12:51.158Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.158Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.159Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.184Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" the"}}} +{"ts":"2025-08-10T03:12:51.184Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.184Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.185Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.210Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" assistant"}}} +{"ts":"2025-08-10T03:12:51.210Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.210Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.212Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.237Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:12:51.237Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.237Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.238Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.248Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.249Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.263Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Probably"}}} +{"ts":"2025-08-10T03:12:51.263Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.263Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.265Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.289Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" user"}}} +{"ts":"2025-08-10T03:12:51.289Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.289Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.291Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.316Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" is"}}} +{"ts":"2025-08-10T03:12:51.316Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.316Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.317Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.342Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" not"}}} +{"ts":"2025-08-10T03:12:51.342Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.342Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.343Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.349Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.351Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.368Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" asking"}}} +{"ts":"2025-08-10T03:12:51.368Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.368Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.369Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.395Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" a"}}} +{"ts":"2025-08-10T03:12:51.395Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.395Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.396Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.421Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" question"}}} +{"ts":"2025-08-10T03:12:51.421Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.421Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.423Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.447Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:12:51.447Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.447Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.448Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.454Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.456Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.474Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Maybe"}}} +{"ts":"2025-08-10T03:12:51.474Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.474Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.475Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.500Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" expecting"}}} +{"ts":"2025-08-10T03:12:51.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.501Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.526Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" acknowledgment"}}} +{"ts":"2025-08-10T03:12:51.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.528Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.553Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:12:51.553Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.553Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.554Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.559Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.561Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.579Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" They"}}} +{"ts":"2025-08-10T03:12:51.579Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.579Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.580Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.606Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" might"}}} +{"ts":"2025-08-10T03:12:51.606Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.606Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.607Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.632Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" want"}}} +{"ts":"2025-08-10T03:12:51.632Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.632Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.633Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.659Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" a"}}} +{"ts":"2025-08-10T03:12:51.659Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.659Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.660Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.662Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.663Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.685Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" reply"}}} +{"ts":"2025-08-10T03:12:51.685Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.685Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.687Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.712Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" like"}}} +{"ts":"2025-08-10T03:12:51.712Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.712Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.713Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.738Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" \""}}} +{"ts":"2025-08-10T03:12:51.738Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.738Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.739Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.765Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.765Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"Hello"}}} +{"ts":"2025-08-10T03:12:51.765Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.765Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.766Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.791Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"\""}}} +{"ts":"2025-08-10T03:12:51.791Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.791Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.792Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.817Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" or"}}} +{"ts":"2025-08-10T03:12:51.817Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.817Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.818Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.843Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" ask"}}} +{"ts":"2025-08-10T03:12:51.843Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.843Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.845Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.870Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" if"}}} +{"ts":"2025-08-10T03:12:51.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.871Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.896Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" something"}}} +{"ts":"2025-08-10T03:12:51.896Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.896Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.897Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.922Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:12:51.922Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.922Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.923Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.948Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" As"}}} +{"ts":"2025-08-10T03:12:51.948Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.948Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.949Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:51.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.974Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" per"}}} +{"ts":"2025-08-10T03:12:51.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:51.975Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.000Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" user"}}} +{"ts":"2025-08-10T03:12:52.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.001Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.026Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" instructions"}}} +{"ts":"2025-08-10T03:12:52.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.027Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.064Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":","}}} +{"ts":"2025-08-10T03:12:52.065Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.065Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.066Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.076Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.077Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.091Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" not"}}} +{"ts":"2025-08-10T03:12:52.091Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.091Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.092Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.117Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" to"}}} +{"ts":"2025-08-10T03:12:52.117Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.117Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.118Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.144Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" change"}}} +{"ts":"2025-08-10T03:12:52.144Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.144Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.145Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.170Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" code"}}} +{"ts":"2025-08-10T03:12:52.170Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.170Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.171Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.181Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.182Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.196Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:12:52.196Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.196Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.198Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.222Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" They"}}} +{"ts":"2025-08-10T03:12:52.222Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.222Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.223Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.248Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" probably"}}} +{"ts":"2025-08-10T03:12:52.248Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.248Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.250Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.275Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" just"}}} +{"ts":"2025-08-10T03:12:52.275Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.275Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.276Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.286Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.287Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.301Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" want"}}} +{"ts":"2025-08-10T03:12:52.301Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.301Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.302Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.327Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" a"}}} +{"ts":"2025-08-10T03:12:52.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.328Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.353Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" response"}}} +{"ts":"2025-08-10T03:12:52.353Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.353Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.354Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.379Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:12:52.379Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.379Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.380Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.387Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.388Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.406Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" I'll"}}} +{"ts":"2025-08-10T03:12:52.406Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.406Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.407Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.432Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" just"}}} +{"ts":"2025-08-10T03:12:52.432Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.432Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.433Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.458Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" reply"}}} +{"ts":"2025-08-10T03:12:52.458Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.458Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.460Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.484Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":" politely"}}} +{"ts":"2025-08-10T03:12:52.484Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.484Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.486Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.492Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.493Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.510Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:12:52.510Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.511Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.512Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.597Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.598Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.694Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":"Hello"}}} +{"ts":"2025-08-10T03:12:52.694Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.694Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.696Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.702Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.703Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.720Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":"!"}}} +{"ts":"2025-08-10T03:12:52.720Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.720Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.721Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.747Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":" How"}}} +{"ts":"2025-08-10T03:12:52.747Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.747Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.748Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.773Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":" can"}}} +{"ts":"2025-08-10T03:12:52.773Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.773Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.774Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.799Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":" I"}}} +{"ts":"2025-08-10T03:12:52.800Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.800Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.801Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.805Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.806Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.826Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":" help"}}} +{"ts":"2025-08-10T03:12:52.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.827Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.852Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":" you"}}} +{"ts":"2025-08-10T03:12:52.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.853Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.878Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":" today"}}} +{"ts":"2025-08-10T03:12:52.878Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.878Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.879Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.904Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message_delta","delta":"?"}}} +{"ts":"2025-08-10T03:12:52.904Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.904Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.906Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.906Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.908Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:52.931Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"agent_message","message":"Hello! How can I help you today?"}}} +{"ts":"2025-08-10T03:12:52.931Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"task_complete","last_agent_message":"Hello! How can I help you today?"}}} +{"ts":"2025-08-10T03:12:52.931Z","dir":"to_tui","kind":"insert_history","lines":3} +{"ts":"2025-08-10T03:12:52.931Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.931Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.931Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:52.932Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:12:53.011Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:12:53.013Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:54.286Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('h'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.286Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:54.288Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:54.368Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('h'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.430Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.430Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:54.432Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:54.510Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.523Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.523Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:54.524Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:54.579Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.663Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:54.664Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:54.721Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.813Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.813Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:54.814Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:54.889Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.889Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:54.890Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:54.931Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:54.971Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.043Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('a'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.043Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.044Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:55.114Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('a'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.128Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('g'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.128Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.130Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:55.194Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('g'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.247Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('a'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.247Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.248Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:55.300Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('i'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.300Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.302Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:55.342Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('a'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.367Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('i'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.461Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('n'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.462Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:55.525Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('n'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.618Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Enter, modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:21:55.618Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.618Z","dir":"to_tui","kind":"insert_history","lines":3} +{"ts":"2025-08-10T03:21:55.619Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.619Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"task_started"}}} +{"ts":"2025-08-10T03:21:55.619Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.619Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.619Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.620Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:55.719Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.721Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:55.820Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.821Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:55.924Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:55.925Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.027Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.028Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.128Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.129Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.228Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.229Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.330Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.332Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.431Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.432Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.533Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.535Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.635Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.636Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.736Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.737Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:56.940Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:56.941Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.042Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.145Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.147Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.249Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.251Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.351Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.352Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.451Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.452Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.552Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.553Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.656Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.658Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.761Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.762Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.864Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.865Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:57.967Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:57.968Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.068Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.069Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.173Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.174Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.278Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.279Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.382Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.383Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.487Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.488Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.590Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.591Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.695Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.696Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.797Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.798Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:58.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:58.901Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.002Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.107Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.210Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.212Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.313Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.314Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.417Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.418Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.522Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.523Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.627Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.628Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.732Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.733Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.834Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.835Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:21:59.934Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:21:59.935Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.036Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.039Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.137Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.139Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.242Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.244Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.347Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.349Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.452Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.454Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.554Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.556Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.660Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.661Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.761Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.762Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.866Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.867Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:00.972Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:00.973Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.077Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.078Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.182Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.183Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.287Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.288Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.392Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.393Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.396Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('w'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:01.396Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.397Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.464Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('w'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:01.496Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.498Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.594Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('r'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:01.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.595Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.601Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.602Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.667Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('r'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:01.706Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.708Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.714Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('i'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:01.714Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.716Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.811Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.813Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.818Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('i'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:01.834Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('t'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:01.834Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.836Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:01.902Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('t'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:01.916Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:01.918Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.021Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.023Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.026Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:02.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.027Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.074Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:02.074Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.075Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.113Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:02.126Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.128Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.150Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:22:02.229Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.231Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.334Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.336Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.440Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.441Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.545Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.546Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.648Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.649Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.752Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.754Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.858Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:02.962Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:02.964Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.063Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.064Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.167Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.168Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.272Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.273Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.378Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.480Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.481Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.585Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.587Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.690Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.691Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.795Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.796Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:03.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:03.901Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.002Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.004Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.108Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.110Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.213Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.214Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.318Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.319Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.419Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.421Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.527Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.627Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.629Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.728Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.729Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.833Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.834Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:04.936Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:04.937Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.041Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.042Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.144Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.146Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.249Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.251Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.354Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.355Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.454Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.455Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.557Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.662Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.761Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.762Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.866Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.867Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:05.969Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:05.971Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:06.070Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:06.071Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:06.172Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:06.173Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:06.277Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:06.278Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:06.382Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:06.522Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:06.626Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:06.627Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:06.731Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:06.732Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:06.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:06.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:06.941Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:06.942Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.046Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.047Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.151Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.152Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.256Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.258Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.363Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.463Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.464Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.568Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.569Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.673Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.674Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.773Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.775Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.879Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.880Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:07.984Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:07.985Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.089Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.090Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.194Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.195Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.299Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.300Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.404Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.405Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.509Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.510Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.614Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.616Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.719Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.721Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.824Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.826Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:08.929Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:08.931Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.035Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.036Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.138Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.139Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.240Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.241Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.345Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.346Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.447Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.449Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.552Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.554Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.654Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.655Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.759Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.760Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.864Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.865Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:09.967Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:09.969Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.073Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.074Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.178Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.179Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.280Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.281Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.386Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.490Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.492Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.596Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.696Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.697Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.800Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.802Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:10.902Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:10.904Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.008Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.009Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.113Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.114Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.218Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.219Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.323Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.324Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.423Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.425Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.529Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.530Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.632Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.633Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.734Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.735Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:11.941Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:11.942Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.046Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.047Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.151Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.152Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.256Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.257Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.361Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.363Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.466Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.468Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.571Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.573Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.676Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.678Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.782Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.783Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.887Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.888Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:12.992Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:12.993Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.097Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.098Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.202Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.204Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.306Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.307Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.407Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.408Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.512Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.513Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.617Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.618Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.724Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.827Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.829Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:13.932Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:13.934Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.037Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.039Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.138Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.140Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.240Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.241Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.345Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.347Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.450Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.452Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.557Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.662Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.763Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.764Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.868Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.870Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:14.973Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:14.975Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.080Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.184Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.185Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.289Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.290Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.395Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.499Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.500Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.600Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.602Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.706Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.707Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.811Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.812Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:15.916Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:15.917Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.021Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.022Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.126Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.127Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.231Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.232Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.334Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.335Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.436Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.510Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":"We"}}} +{"ts":"2025-08-10T03:22:16.510Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.510Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:22:16.510Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.510Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.511Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.535Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.535Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" need"}}} +{"ts":"2025-08-10T03:22:16.561Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" to"}}} +{"ts":"2025-08-10T03:22:16.587Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" respond"}}} +{"ts":"2025-08-10T03:22:16.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.596Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.613Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:22:16.613Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.613Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.614Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.638Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" The"}}} +{"ts":"2025-08-10T03:22:16.639Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.639Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.640Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.640Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.641Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.665Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" user"}}} +{"ts":"2025-08-10T03:22:16.665Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.665Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.666Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.691Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" said"}}} +{"ts":"2025-08-10T03:22:16.691Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.691Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.692Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.717Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" \""}}} +{"ts":"2025-08-10T03:22:16.717Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.717Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.718Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.743Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":"hello"}}} +{"ts":"2025-08-10T03:22:16.743Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.743Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.743Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.744Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.769Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" again"}}} +{"ts":"2025-08-10T03:22:16.769Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.769Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.771Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.796Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":"\"."}}} +{"ts":"2025-08-10T03:22:16.796Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.796Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.797Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.822Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" No"}}} +{"ts":"2025-08-10T03:22:16.822Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.822Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.824Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.848Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.848Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" specific"}}} +{"ts":"2025-08-10T03:22:16.848Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.848Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.849Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.874Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" task"}}} +{"ts":"2025-08-10T03:22:16.874Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.874Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.876Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.900Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:22:16.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.902Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.926Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Probably"}}} +{"ts":"2025-08-10T03:22:16.926Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.927Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.928Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.948Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.950Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.953Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" just"}}} +{"ts":"2025-08-10T03:22:16.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.954Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:16.979Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":" greeting"}}} +{"ts":"2025-08-10T03:22:16.979Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.979Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:16.980Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:22:17.005Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.006Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.007Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.053Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.055Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.158Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.198Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":"Hi"}}} +{"ts":"2025-08-10T03:22:17.198Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.199Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.200Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.225Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":"!"}}} +{"ts":"2025-08-10T03:22:17.226Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.226Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.227Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.253Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" If"}}} +{"ts":"2025-08-10T03:22:17.253Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.253Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.254Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.259Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.260Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.280Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" you"}}} +{"ts":"2025-08-10T03:22:17.280Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.280Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.281Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.307Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" have"}}} +{"ts":"2025-08-10T03:22:17.307Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.308Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.309Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.335Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:22:17.335Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.335Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.336Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.362Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" task"}}} +{"ts":"2025-08-10T03:22:17.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.363Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.389Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" or"}}} +{"ts":"2025-08-10T03:22:17.390Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.390Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.391Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.417Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" question"}}} +{"ts":"2025-08-10T03:22:17.417Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.417Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.419Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.445Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" about"}}} +{"ts":"2025-08-10T03:22:17.445Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.445Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.446Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.467Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.468Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.473Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:22:17.473Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.473Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.475Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.501Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" repository"}}} +{"ts":"2025-08-10T03:22:17.501Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.501Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.503Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.530Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:22:17.530Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.530Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.535Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.559Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" feel"}}} +{"ts":"2025-08-10T03:22:17.559Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.560Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.561Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.572Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.573Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.588Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" free"}}} +{"ts":"2025-08-10T03:22:17.588Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.588Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.589Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.615Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:22:17.615Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.615Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.617Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.643Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" let"}}} +{"ts":"2025-08-10T03:22:17.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.645Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.670Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" me"}}} +{"ts":"2025-08-10T03:22:17.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.672Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.677Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.678Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.697Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":" know"}}} +{"ts":"2025-08-10T03:22:17.697Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.697Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.699Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.723Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message_delta","delta":"!"}}} +{"ts":"2025-08-10T03:22:17.723Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.724Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.725Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.750Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"agent_message","message":"Hi! If you have a task or question about the repository, feel free to let me know!"}}} +{"ts":"2025-08-10T03:22:17.750Z","dir":"to_tui","kind":"codex_event","payload":{"id":"3","msg":{"type":"task_complete","last_agent_message":"Hi! If you have a task or question about the repository, feel free to let me know!"}}} +{"ts":"2025-08-10T03:22:17.750Z","dir":"to_tui","kind":"insert_history","lines":3} +{"ts":"2025-08-10T03:22:17.750Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.750Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.750Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.751Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:22:17.782Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:22:17.807Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:02.908Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:02.909Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:02.935Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:02.937Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:02.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:02.965Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:04.584Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:04.585Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:04.617Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:04.621Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:04.648Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:04.650Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:04.730Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:04.732Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:04.764Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:04.765Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:04.792Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:04.794Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:04.823Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:04.827Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:04.849Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:04.850Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:18.326Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('w'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:18.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:18.329Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:18.427Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('w'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:18.664Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('r'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:18.664Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:18.666Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:18.739Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('r'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:18.793Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('i'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:18.794Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:18.797Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:18.868Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('t'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:18.868Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:18.870Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:18.884Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('i'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:18.933Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('t'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.033Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.033Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.035Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.073Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.074Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.076Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.113Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.137Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.214Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('m'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.214Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.216Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.280Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.280Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.281Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.288Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('m'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.350Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('e'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.367Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.372Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.435Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.488Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('a'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.488Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.490Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.550Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.551Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.553Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.568Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('a'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.617Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.749Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.750Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.751Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.810Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.846Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:19.847Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:19.849Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:19.908Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('l'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:20.152Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:20.152Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:20.155Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:20.204Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:20.501Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('n'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:20.502Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:20.503Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:20.571Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('n'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:20.773Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Backspace, modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:20.773Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:20.775Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:20.901Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Backspace, modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:20.902Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:20.905Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:21.073Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Backspace, modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.075Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:21.076Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:21.205Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:21.207Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:21.263Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.401Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('n'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.402Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:21.408Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:21.505Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('g'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:21.509Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('n'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.509Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:21.579Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('g'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.599Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.600Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:21.601Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:21.683Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.771Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('s'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.771Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:21.773Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:21.835Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('s'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.954Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('t'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:21.955Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:21.958Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.005Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('t'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.026Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.028Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.096Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('o'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.114Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('r'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.114Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.116Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.198Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('r'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.213Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('y'), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.214Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.216Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.277Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.281Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.283Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.285Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('y'), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.331Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char(' '), modifiers: KeyModifiers(0x0), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.432Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Backspace, modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.432Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.435Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.525Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Enter, modifiers: KeyModifiers(0x0), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:23:22.525Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.525Z","dir":"to_tui","kind":"insert_history","lines":3} +{"ts":"2025-08-10T03:23:22.525Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.526Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"task_started"}}} +{"ts":"2025-08-10T03:23:22.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.527Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.628Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.629Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.733Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.734Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.835Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.940Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.942Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:22.993Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"User"}}} +{"ts":"2025-08-10T03:23:22.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.993Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:22.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:22.994Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.021Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":":"}}} +{"ts":"2025-08-10T03:23:23.031Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.031Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.032Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.047Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.048Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:23.048Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.048Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.049Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.075Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"write"}}} +{"ts":"2025-08-10T03:23:23.075Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.075Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.076Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.102Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" me"}}} +{"ts":"2025-08-10T03:23:23.102Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.102Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.103Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.129Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:23.130Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.130Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.131Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.151Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.152Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.157Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" long"}}} +{"ts":"2025-08-10T03:23:23.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.158Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.184Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" story"}}} +{"ts":"2025-08-10T03:23:23.184Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.184Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.185Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.211Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"\"."}}} +{"ts":"2025-08-10T03:23:23.211Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.211Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.213Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.239Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" They"}}} +{"ts":"2025-08-10T03:23:23.239Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.239Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.240Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.252Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.254Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.266Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" didn't"}}} +{"ts":"2025-08-10T03:23:23.266Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.266Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.267Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.293Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" specify"}}} +{"ts":"2025-08-10T03:23:23.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.295Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.321Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" details"}}} +{"ts":"2025-08-10T03:23:23.321Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.321Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.322Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.348Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:23.348Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.349Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.350Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.357Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.358Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.376Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" I"}}} +{"ts":"2025-08-10T03:23:23.376Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.376Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.377Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.403Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" guess"}}} +{"ts":"2025-08-10T03:23:23.403Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.403Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.405Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.431Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" we"}}} +{"ts":"2025-08-10T03:23:23.431Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.431Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.432Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.459Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" just"}}} +{"ts":"2025-08-10T03:23:23.459Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.459Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.460Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.460Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.462Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.487Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" create"}}} +{"ts":"2025-08-10T03:23:23.487Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.487Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.488Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.514Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:23.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.516Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.542Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" story"}}} +{"ts":"2025-08-10T03:23:23.542Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.542Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.543Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.563Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.569Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:23.569Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.569Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.570Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.596Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Need"}}} +{"ts":"2025-08-10T03:23:23.596Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.596Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.598Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.624Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" no"}}} +{"ts":"2025-08-10T03:23:23.624Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.624Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.625Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.652Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" code"}}} +{"ts":"2025-08-10T03:23:23.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.653Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.666Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.668Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.679Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" changes"}}} +{"ts":"2025-08-10T03:23:23.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.680Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.707Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:23.707Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.707Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.708Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.734Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Could"}}} +{"ts":"2025-08-10T03:23:23.734Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.734Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.735Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.764Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" output"}}} +{"ts":"2025-08-10T03:23:23.764Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.764Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.766Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.770Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.771Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.792Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:23.792Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.792Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.793Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.819Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" story"}}} +{"ts":"2025-08-10T03:23:23.819Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.819Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.820Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.846Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:23.846Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.846Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.848Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.874Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Should"}}} +{"ts":"2025-08-10T03:23:23.874Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.874Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.875Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.875Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.901Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" we"}}} +{"ts":"2025-08-10T03:23:23.901Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.901Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.903Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.929Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" produce"}}} +{"ts":"2025-08-10T03:23:23.929Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.929Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.930Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.956Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:23.956Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.956Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.957Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.978Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.979Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:23.983Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" story"}}} +{"ts":"2025-08-10T03:23:23.983Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.983Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:23.985Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.010Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"?"}}} +{"ts":"2025-08-10T03:23:24.010Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.010Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.012Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.038Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Yes"}}} +{"ts":"2025-08-10T03:23:24.038Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.038Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.039Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.065Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:24.065Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.065Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.066Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.084Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.092Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Probably"}}} +{"ts":"2025-08-10T03:23:24.093Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.093Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.094Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.120Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:24.120Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.120Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.121Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.147Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" random"}}} +{"ts":"2025-08-10T03:23:24.147Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.148Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.149Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.175Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" long"}}} +{"ts":"2025-08-10T03:23:24.175Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.175Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.176Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.188Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.190Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.202Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" story"}}} +{"ts":"2025-08-10T03:23:24.202Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.202Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.204Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.230Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:24.230Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.230Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.231Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.257Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" Let's"}}} +{"ts":"2025-08-10T03:23:24.257Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.257Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.258Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.284Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":" produce"}}} +{"ts":"2025-08-10T03:23:24.284Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.284Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.285Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.295Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.310Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_reasoning_raw_content_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:24.310Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.310Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.311Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.398Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.400Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.495Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"**"}}} +{"ts":"2025-08-10T03:23:24.495Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.495Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.496Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.503Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.505Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.521Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"The"}}} +{"ts":"2025-08-10T03:23:24.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.523Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.548Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Last"}}} +{"ts":"2025-08-10T03:23:24.548Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.548Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.549Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.574Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:24.575Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.575Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.576Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.601Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:24.601Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.601Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.602Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.609Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.610Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.627Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ever"}}} +{"ts":"2025-08-10T03:23:24.627Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.627Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.628Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.653Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"night"}}} +{"ts":"2025-08-10T03:23:24.653Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.653Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.655Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.679Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"**\n\n"}}} +{"ts":"2025-08-10T03:23:24.680Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.680Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:24.680Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.681Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.706Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"In"}}} +{"ts":"2025-08-10T03:23:24.706Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.706Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.707Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.714Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.715Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.732Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:24.732Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.732Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.733Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.735Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:24.735Z","dir":"to_tui","kind":"insert_history","lines":2} +{"ts":"2025-08-10T03:23:24.735Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:24.735Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.736Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.758Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" valley"}}} +{"ts":"2025-08-10T03:23:24.758Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.759Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.760Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.785Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:24.785Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.785Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.786Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.790Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:24.790Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:24.812Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ever"}}} +{"ts":"2025-08-10T03:23:24.812Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.812Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.813Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.819Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.820Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.838Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"g"}}} +{"ts":"2025-08-10T03:23:24.838Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.838Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.839Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.865Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"len"}}} +{"ts":"2025-08-10T03:23:24.865Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.865Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.867Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.892Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:24.892Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.892Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.893Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.919Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" where"}}} +{"ts":"2025-08-10T03:23:24.919Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.919Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.920Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.924Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.925Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.945Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:24.945Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.945Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.947Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.972Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mist"}}} +{"ts":"2025-08-10T03:23:24.972Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.972Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.973Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:24.999Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cl"}}} +{"ts":"2025-08-10T03:23:24.999Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:24.999Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.001Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.026Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ung"}}} +{"ts":"2025-08-10T03:23:25.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.027Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.028Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.029Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.052Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:25.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.053Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.078Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:25.078Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.080Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.105Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" emerald"}}} +{"ts":"2025-08-10T03:23:25.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.106Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.131Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" slopes"}}} +{"ts":"2025-08-10T03:23:25.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.132Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.157Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" like"}}} +{"ts":"2025-08-10T03:23:25.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.159Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.184Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:25.184Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.184Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.185Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.210Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sil"}}} +{"ts":"2025-08-10T03:23:25.210Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.210Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.212Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.236Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.237Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ken"}}} +{"ts":"2025-08-10T03:23:25.237Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.237Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.237Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.263Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" shaw"}}} +{"ts":"2025-08-10T03:23:25.263Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.263Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.264Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.289Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"l"}}} +{"ts":"2025-08-10T03:23:25.289Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.289Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.290Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.315Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:25.315Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.315Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.317Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.341Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.342Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:25.342Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.342Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.342Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.369Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" night"}}} +{"ts":"2025-08-10T03:23:25.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.370Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.395Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" never"}}} +{"ts":"2025-08-10T03:23:25.395Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.395Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.397Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.422Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" truly"}}} +{"ts":"2025-08-10T03:23:25.422Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.422Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.424Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.446Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.447Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.449Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" surrendered"}}} +{"ts":"2025-08-10T03:23:25.449Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.449Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.450Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.476Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:25.476Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.476Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.477Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.503Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Though"}}} +{"ts":"2025-08-10T03:23:25.503Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.503Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.504Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.530Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:25.530Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.530Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.531Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.551Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.552Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.557Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" moon"}}} +{"ts":"2025-08-10T03:23:25.557Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.557Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.558Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.583Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" rose"}}} +{"ts":"2025-08-10T03:23:25.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.585Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.610Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" each"}}} +{"ts":"2025-08-10T03:23:25.610Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.610Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.612Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.637Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" evening"}}} +{"ts":"2025-08-10T03:23:25.637Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.637Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.638Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.656Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.658Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.663Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:25.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.665Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.689Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:25.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.690Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.691Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.716Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pall"}}} +{"ts":"2025-08-10T03:23:25.716Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.716Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.717Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.743Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"id"}}} +{"ts":"2025-08-10T03:23:25.743Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.743Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.744Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.761Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.763Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.769Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" glow"}}} +{"ts":"2025-08-10T03:23:25.769Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.769Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.770Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.796Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" was"}}} +{"ts":"2025-08-10T03:23:25.796Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.796Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.797Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.823Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" always"}}} +{"ts":"2025-08-10T03:23:25.823Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.823Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.824Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.850Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pierced"}}} +{"ts":"2025-08-10T03:23:25.850Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.850Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.851Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.866Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.868Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.877Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" by"}}} +{"ts":"2025-08-10T03:23:25.877Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.877Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.878Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.903Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sparks"}}} +{"ts":"2025-08-10T03:23:25.903Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.904Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.905Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.930Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:25.930Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.930Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.932Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.957Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" danced"}}} +{"ts":"2025-08-10T03:23:25.957Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.957Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.958Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.968Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.969Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:25.984Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" from"}}} +{"ts":"2025-08-10T03:23:25.984Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.984Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:25.985Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.010Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:26.010Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.010Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.012Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.037Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ancient"}}} +{"ts":"2025-08-10T03:23:26.037Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.037Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.039Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.066Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:26.066Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.066Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.067Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.073Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.075Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.092Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"stone"}}} +{"ts":"2025-08-10T03:23:26.092Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.092Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.093Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.118Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:26.118Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.118Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.120Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.145Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:26.145Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.145Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.146Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.172Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" relic"}}} +{"ts":"2025-08-10T03:23:26.172Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.172Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.173Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.178Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.180Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.198Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" said"}}} +{"ts":"2025-08-10T03:23:26.198Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.198Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.200Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.225Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:26.225Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.225Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.226Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.252Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" house"}}} +{"ts":"2025-08-10T03:23:26.252Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.252Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.253Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.278Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:26.278Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.278Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.279Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.285Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.305Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" living"}}} +{"ts":"2025-08-10T03:23:26.305Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.305Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.306Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.331Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fire"}}} +{"ts":"2025-08-10T03:23:26.331Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.331Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.332Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.358Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:26.358Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.358Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.359Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.384Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.385Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:26.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.386Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.412Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:26.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.413Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.438Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" rested"}}} +{"ts":"2025-08-10T03:23:26.438Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.438Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.440Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.465Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" in"}}} +{"ts":"2025-08-10T03:23:26.465Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.465Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.466Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.485Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.486Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.492Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:26.492Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.492Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.494Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.521Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cavern"}}} +{"ts":"2025-08-10T03:23:26.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.522Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.547Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" beneath"}}} +{"ts":"2025-08-10T03:23:26.547Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.548Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.549Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.574Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:26.574Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.574Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.575Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.590Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.591Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.600Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" old"}}} +{"ts":"2025-08-10T03:23:26.600Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.600Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.601Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.627Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Keep"}}} +{"ts":"2025-08-10T03:23:26.627Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.627Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.628Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.653Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:26.653Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.653Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.654Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.679Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" L"}}} +{"ts":"2025-08-10T03:23:26.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.681Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.695Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.696Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.705Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"iora"}}} +{"ts":"2025-08-10T03:23:26.705Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.705Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.707Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.732Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:26.732Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.732Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.733Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.758Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" guarded"}}} +{"ts":"2025-08-10T03:23:26.758Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.758Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.759Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.784Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" by"}}} +{"ts":"2025-08-10T03:23:26.784Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.784Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.785Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.795Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.796Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.810Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:26.810Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.810Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.812Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.836Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Order"}}} +{"ts":"2025-08-10T03:23:26.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.863Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:26.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.864Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.890Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Light"}}} +{"ts":"2025-08-10T03:23:26.890Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.890Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.891Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.901Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.916Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:26.917Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.917Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.918Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.943Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" who"}}} +{"ts":"2025-08-10T03:23:26.943Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.943Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.944Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.972Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sw"}}} +{"ts":"2025-08-10T03:23:26.972Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.972Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:26.974Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:26.999Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ore"}}} +{"ts":"2025-08-10T03:23:27.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.001Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.005Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.006Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.026Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:27.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.027Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.053Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" keep"}}} +{"ts":"2025-08-10T03:23:27.053Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.053Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.054Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.080Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" it"}}} +{"ts":"2025-08-10T03:23:27.080Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.080Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.081Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.106Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.107Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" unt"}}} +{"ts":"2025-08-10T03:23:27.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.108Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.133Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"arn"}}} +{"ts":"2025-08-10T03:23:27.134Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.134Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.135Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.160Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ished"}}} +{"ts":"2025-08-10T03:23:27.160Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.160Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.162Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.187Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" from"}}} +{"ts":"2025-08-10T03:23:27.187Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.187Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.188Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.212Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.213Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.214Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" any"}}} +{"ts":"2025-08-10T03:23:27.214Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.214Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.215Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.241Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mortal"}}} +{"ts":"2025-08-10T03:23:27.241Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.241Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.242Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.267Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" touch"}}} +{"ts":"2025-08-10T03:23:27.267Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.267Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.268Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.293Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:27.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.293Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:27.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.294Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.317Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.318Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.319Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Among"}}} +{"ts":"2025-08-10T03:23:27.319Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.319Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.320Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.345Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:27.345Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:27.345Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.345Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:27.346Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.346Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.346Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.372Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" village"}}} +{"ts":"2025-08-10T03:23:27.372Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.372Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.373Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.398Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" was"}}} +{"ts":"2025-08-10T03:23:27.398Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.398Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.399Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.400Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:27.400Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:27.400Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:27.400Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.402Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.422Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.424Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:27.450Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" boy"}}} +{"ts":"2025-08-10T03:23:27.452Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:27.470Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.470Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.470Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.470Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.470Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:27.472Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.476Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" named"}}} +{"ts":"2025-08-10T03:23:27.476Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.476Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.477Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.507Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ca"}}} +{"ts":"2025-08-10T03:23:27.508Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.508Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.509Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.527Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.528Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.535Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:27.535Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.535Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.536Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.561Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:27.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.563Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.588Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" He"}}} +{"ts":"2025-08-10T03:23:27.588Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.588Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.589Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.615Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:27.615Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.615Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.616Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.631Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.632Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.642Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" grown"}}} +{"ts":"2025-08-10T03:23:27.642Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.642Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.643Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.669Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" up"}}} +{"ts":"2025-08-10T03:23:27.669Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.669Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.670Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.696Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" hearing"}}} +{"ts":"2025-08-10T03:23:27.696Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.696Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.697Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.722Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:27.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.724Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.736Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.737Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.749Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" legends"}}} +{"ts":"2025-08-10T03:23:27.749Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.749Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.750Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.775Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" –"}}} +{"ts":"2025-08-10T03:23:27.775Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.775Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.777Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.802Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:27.802Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.802Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.803Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.829Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" tale"}}} +{"ts":"2025-08-10T03:23:27.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.830Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.841Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.842Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.856Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:27.856Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.856Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.857Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.882Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:27.882Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.882Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.884Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.909Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:27.909Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.909Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.910Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.936Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"stone"}}} +{"ts":"2025-08-10T03:23:27.936Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.936Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.938Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.946Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.947Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.963Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:27.963Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.963Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.964Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:27.990Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" kept"}}} +{"ts":"2025-08-10T03:23:27.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:27.992Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.017Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:28.017Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.017Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.019Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.045Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" valley"}}} +{"ts":"2025-08-10T03:23:28.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.046Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.046Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.072Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" alive"}}} +{"ts":"2025-08-10T03:23:28.072Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.072Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.073Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.099Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:28.099Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.099Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.100Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.125Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:28.125Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.125Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.127Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.148Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.149Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.152Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" those"}}} +{"ts":"2025-08-10T03:23:28.152Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.152Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.154Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.179Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" who"}}} +{"ts":"2025-08-10T03:23:28.179Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.179Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.180Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.206Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" dared"}}} +{"ts":"2025-08-10T03:23:28.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.207Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.233Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:28.233Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.233Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.234Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.253Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.254Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.260Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" steal"}}} +{"ts":"2025-08-10T03:23:28.260Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.260Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.261Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.287Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:28.287Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.287Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.288Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.314Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" heart"}}} +{"ts":"2025-08-10T03:23:28.314Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.314Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.315Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.342Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:28.342Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.342Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.343Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.358Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.359Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.369Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:28.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.370Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.396Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:28.397Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.397Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.398Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.427Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:28.427Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.427Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.429Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.456Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cursed"}}} +{"ts":"2025-08-10T03:23:28.456Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.457Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.458Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.458Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.460Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.485Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ones"}}} +{"ts":"2025-08-10T03:23:28.485Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.485Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.486Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.513Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" who"}}} +{"ts":"2025-08-10T03:23:28.513Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.513Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.514Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.544Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" turned"}}} +{"ts":"2025-08-10T03:23:28.544Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.544Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.546Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.563Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.565Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.571Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" from"}}} +{"ts":"2025-08-10T03:23:28.571Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.571Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.573Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.598Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:28.598Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.598Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.600Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.625Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:28.625Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.625Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.626Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.652Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:28.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.653Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.664Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.665Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.678Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ca"}}} +{"ts":"2025-08-10T03:23:28.678Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.678Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.680Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.705Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:28.705Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.705Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.707Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.732Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:28.732Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.732Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.734Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.759Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mother"}}} +{"ts":"2025-08-10T03:23:28.759Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.759Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.761Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.768Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.770Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.786Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:28.786Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.786Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.787Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.813Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:28.813Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.813Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.814Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.840Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" healer"}}} +{"ts":"2025-08-10T03:23:28.840Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.840Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.841Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.867Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" known"}}} +{"ts":"2025-08-10T03:23:28.867Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.867Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.868Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.871Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.894Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" as"}}} +{"ts":"2025-08-10T03:23:28.894Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.894Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.895Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.920Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Mae"}}} +{"ts":"2025-08-10T03:23:28.920Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.920Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.922Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.947Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ve"}}} +{"ts":"2025-08-10T03:23:28.947Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.947Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.948Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:28.973Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.974Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:28.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:28.975Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.000Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" warned"}}} +{"ts":"2025-08-10T03:23:29.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.001Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.027Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" him"}}} +{"ts":"2025-08-10T03:23:29.027Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.027Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.029Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.054Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" never"}}} +{"ts":"2025-08-10T03:23:29.054Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.054Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.055Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.078Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.079Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.086Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:29.086Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.086Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.087Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.112Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" venture"}}} +{"ts":"2025-08-10T03:23:29.112Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.112Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.114Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.139Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" beyond"}}} +{"ts":"2025-08-10T03:23:29.139Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.140Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.141Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.167Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:29.167Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.167Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.168Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.183Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.184Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.193Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" river"}}} +{"ts":"2025-08-10T03:23:29.193Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.193Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.194Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.220Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:29.220Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.220Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.221Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.247Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Yet"}}} +{"ts":"2025-08-10T03:23:29.247Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.247Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.248Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.274Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:29.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.275Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.288Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.289Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.300Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" eyes"}}} +{"ts":"2025-08-10T03:23:29.300Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.300Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.302Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.327Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" were"}}} +{"ts":"2025-08-10T03:23:29.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.328Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.354Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" always"}}} +{"ts":"2025-08-10T03:23:29.354Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.354Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.356Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.381Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" drawn"}}} +{"ts":"2025-08-10T03:23:29.381Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.381Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.382Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.393Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.395Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.408Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" toward"}}} +{"ts":"2025-08-10T03:23:29.408Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.408Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.409Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.434Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:29.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.436Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.461Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" gl"}}} +{"ts":"2025-08-10T03:23:29.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.462Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.488Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"immer"}}} +{"ts":"2025-08-10T03:23:29.488Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.488Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.489Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.497Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.499Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.514Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ing"}}} +{"ts":"2025-08-10T03:23:29.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.516Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.541Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" blue"}}} +{"ts":"2025-08-10T03:23:29.541Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.541Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.542Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.569Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:29.569Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.569Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.571Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.595Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" peek"}}} +{"ts":"2025-08-10T03:23:29.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.596Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.599Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.600Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.621Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ed"}}} +{"ts":"2025-08-10T03:23:29.621Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.621Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.623Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.649Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" from"}}} +{"ts":"2025-08-10T03:23:29.649Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.649Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.650Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.675Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:29.675Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.675Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.677Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.702Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.702Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" depths"}}} +{"ts":"2025-08-10T03:23:29.702Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.702Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.703Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.729Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:29.729Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.729Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.730Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.755Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:29.756Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.756Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.757Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.782Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" keep"}}} +{"ts":"2025-08-10T03:23:29.782Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.782Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.784Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.807Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.808Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.809Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:29.809Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.810Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:29.810Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.811Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.836Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"One"}}} +{"ts":"2025-08-10T03:23:29.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.838Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.863Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" crisp"}}} +{"ts":"2025-08-10T03:23:29.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.865Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.865Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:29.865Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:29.865Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.866Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.890Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" autumn"}}} +{"ts":"2025-08-10T03:23:29.890Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.890Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.891Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.912Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.913Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.917Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" night"}}} +{"ts":"2025-08-10T03:23:29.917Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.917Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.918Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.920Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:29.920Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:29.920Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:29.920Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.921Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.944Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:29.971Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:29.975Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:29.991Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.991Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.991Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.991Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.991Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:29.992Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:29.998Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" strange"}}} +{"ts":"2025-08-10T03:23:29.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:29.999Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.017Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.018Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.025Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:30.025Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.025Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.026Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.052Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flutter"}}} +{"ts":"2025-08-10T03:23:30.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.053Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.079Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ed"}}} +{"ts":"2025-08-10T03:23:30.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.080Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.114Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" across"}}} +{"ts":"2025-08-10T03:23:30.115Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.115Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.116Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.122Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.123Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.142Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:30.142Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.142Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.143Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.169Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sky"}}} +{"ts":"2025-08-10T03:23:30.169Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.169Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.170Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.196Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:30.196Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.196Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.197Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.223Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:30.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.225Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.250Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" comet"}}} +{"ts":"2025-08-10T03:23:30.253Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.253Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.255Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.277Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" streak"}}} +{"ts":"2025-08-10T03:23:30.277Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.277Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.278Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.304Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:30.304Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.304Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.305Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.328Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.330Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.331Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" hung"}}} +{"ts":"2025-08-10T03:23:30.331Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.331Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.332Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.358Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" longer"}}} +{"ts":"2025-08-10T03:23:30.358Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.358Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.359Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.385Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" than"}}} +{"ts":"2025-08-10T03:23:30.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.386Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.412Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" it"}}} +{"ts":"2025-08-10T03:23:30.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.413Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.433Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.435Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.438Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" should"}}} +{"ts":"2025-08-10T03:23:30.439Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.439Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.440Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.465Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:30.465Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.465Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.467Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.492Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ca"}}} +{"ts":"2025-08-10T03:23:30.492Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.492Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.493Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:30.519Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.519Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.520Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.538Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.540Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.546Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" slipped"}}} +{"ts":"2025-08-10T03:23:30.546Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.546Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.548Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.573Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" away"}}} +{"ts":"2025-08-10T03:23:30.573Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.573Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.575Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.600Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:30.600Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.600Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.601Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.627Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:30.627Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.627Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.628Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.644Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.645Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.654Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cloak"}}} +{"ts":"2025-08-10T03:23:30.654Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.654Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.655Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.681Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:30.681Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.681Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.682Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.708Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" blur"}}} +{"ts":"2025-08-10T03:23:30.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.710Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.735Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" against"}}} +{"ts":"2025-08-10T03:23:30.735Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.735Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.737Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.749Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.750Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.762Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:30.762Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.762Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.763Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.789Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" silver"}}} +{"ts":"2025-08-10T03:23:30.789Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.789Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.790Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.816Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ed"}}} +{"ts":"2025-08-10T03:23:30.816Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.816Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.817Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.843Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" river"}}} +{"ts":"2025-08-10T03:23:30.843Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.843Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.844Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.854Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.855Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.870Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"banks"}}} +{"ts":"2025-08-10T03:23:30.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.871Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.897Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:30.897Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.897Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.898Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.924Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" He"}}} +{"ts":"2025-08-10T03:23:30.924Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.924Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.925Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.951Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ran"}}} +{"ts":"2025-08-10T03:23:30.951Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.951Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.952Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.959Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.960Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:30.978Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" into"}}} +{"ts":"2025-08-10T03:23:30.978Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.978Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:30.979Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:31.005Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.005Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.006Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.032Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" forest"}}} +{"ts":"2025-08-10T03:23:31.032Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.032Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.034Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.059Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:31.059Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.059Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.059Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.061Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.086Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" guided"}}} +{"ts":"2025-08-10T03:23:31.086Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.086Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.087Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.113Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" by"}}} +{"ts":"2025-08-10T03:23:31.113Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.113Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.114Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.149Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:31.149Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.149Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.150Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.164Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.165Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.177Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" comet"}}} +{"ts":"2025-08-10T03:23:31.177Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.177Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.178Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.204Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"’s"}}} +{"ts":"2025-08-10T03:23:31.204Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.204Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.205Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.231Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" tail"}}} +{"ts":"2025-08-10T03:23:31.231Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.231Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.232Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.258Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:31.258Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.258Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.260Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.268Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.270Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.285Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" until"}}} +{"ts":"2025-08-10T03:23:31.285Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.285Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.286Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.312Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" he"}}} +{"ts":"2025-08-10T03:23:31.312Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.312Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.313Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.339Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" found"}}} +{"ts":"2025-08-10T03:23:31.339Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.339Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.340Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.366Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" himself"}}} +{"ts":"2025-08-10T03:23:31.366Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.366Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.367Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.373Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.375Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.393Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" at"}}} +{"ts":"2025-08-10T03:23:31.393Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.393Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.394Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.419Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:31.419Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.419Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.421Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.446Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" base"}}} +{"ts":"2025-08-10T03:23:31.447Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.447Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.448Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.473Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:31.473Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.473Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.473Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.475Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.500Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:31.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.502Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.527Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Keep"}}} +{"ts":"2025-08-10T03:23:31.527Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.527Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.529Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.554Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:31.554Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.554Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.555Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.578Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.580Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.581Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" L"}}} +{"ts":"2025-08-10T03:23:31.581Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.581Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.583Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.608Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"iora"}}} +{"ts":"2025-08-10T03:23:31.609Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.609Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.610Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.635Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:31.635Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.635Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.637Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.663Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:31.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.664Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.683Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.685Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.694Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:31.694Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.694Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.696Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.722Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" walls"}}} +{"ts":"2025-08-10T03:23:31.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.723Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.749Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" gle"}}} +{"ts":"2025-08-10T03:23:31.749Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.749Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.750Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.776Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"amed"}}} +{"ts":"2025-08-10T03:23:31.776Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.776Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.777Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.789Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.790Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.803Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:31.803Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.803Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.804Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.830Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:31.830Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.830Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.832Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.857Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" an"}}} +{"ts":"2025-08-10T03:23:31.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.858Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.884Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ancient"}}} +{"ts":"2025-08-10T03:23:31.884Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.884Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.886Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.894Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.895Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.911Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" oak"}}} +{"ts":"2025-08-10T03:23:31.911Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.911Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.912Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.938Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:31.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.940Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.965Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" roots"}}} +{"ts":"2025-08-10T03:23:31.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.967Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.992Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" tw"}}} +{"ts":"2025-08-10T03:23:31.992Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.992Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:31.994Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:31.999Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.000Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.019Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ined"}}} +{"ts":"2025-08-10T03:23:32.020Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.020Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.021Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.047Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" like"}}} +{"ts":"2025-08-10T03:23:32.047Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.047Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.048Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.074Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" silver"}}} +{"ts":"2025-08-10T03:23:32.074Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.074Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.075Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.101Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cords"}}} +{"ts":"2025-08-10T03:23:32.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.102Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.102Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.104Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.128Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" across"}}} +{"ts":"2025-08-10T03:23:32.128Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.128Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.129Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.155Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:32.155Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.155Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.156Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.182Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:32.182Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.182Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.183Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.207Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.209Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.209Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:32.209Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.209Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:32.209Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.211Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.236Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Inside"}}} +{"ts":"2025-08-10T03:23:32.236Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.236Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.237Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.263Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:32.263Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:32.263Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.263Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:32.263Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.263Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.264Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.290Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" keep"}}} +{"ts":"2025-08-10T03:23:32.310Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.310Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.311Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.312Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.314Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.314Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:32.314Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:32.314Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:32.314Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.315Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.317Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:32.344Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:32.364Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:32.371Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" corridors"}}} +{"ts":"2025-08-10T03:23:32.399Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" were"}}} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.413Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.413Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.426Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" maze"}}} +{"ts":"2025-08-10T03:23:32.426Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.426Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.427Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.453Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"-like"}}} +{"ts":"2025-08-10T03:23:32.453Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.453Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.454Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.480Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:32.480Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.480Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.481Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.507Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:32.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.508Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.518Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.519Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.534Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" air"}}} +{"ts":"2025-08-10T03:23:32.534Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.534Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.535Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.561Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" heavy"}}} +{"ts":"2025-08-10T03:23:32.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.563Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.588Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" with"}}} +{"ts":"2025-08-10T03:23:32.588Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.588Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.590Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.615Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:32.615Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.615Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.617Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.618Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.620Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.643Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" scent"}}} +{"ts":"2025-08-10T03:23:32.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.644Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.670Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:32.670Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.670Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.671Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.697Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:32.697Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.697Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.698Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.723Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.725Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.726Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:32.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.728Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.754Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" lingering"}}} +{"ts":"2025-08-10T03:23:32.754Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.754Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.756Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.781Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" incense"}}} +{"ts":"2025-08-10T03:23:32.781Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.781Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.783Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.808Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:32.808Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.808Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.810Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.828Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.830Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.835Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" At"}}} +{"ts":"2025-08-10T03:23:32.835Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.835Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.863Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:32.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.864Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.890Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" heart"}}} +{"ts":"2025-08-10T03:23:32.890Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.890Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.891Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.917Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:32.917Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.917Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.918Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.933Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.935Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.944Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:32.944Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.944Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.945Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.971Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" labyrinth"}}} +{"ts":"2025-08-10T03:23:32.971Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.971Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.972Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:32.998Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" lay"}}} +{"ts":"2025-08-10T03:23:32.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:32.999Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.025Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:33.025Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.025Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.026Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.039Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.040Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.052Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Chamber"}}} +{"ts":"2025-08-10T03:23:33.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.053Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.080Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:33.080Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.080Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.081Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.107Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:33.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.108Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.134Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:33.134Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.134Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.135Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.144Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.145Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.161Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:33.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.162Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.188Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" vast"}}} +{"ts":"2025-08-10T03:23:33.188Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.188Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.189Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.215Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" vault"}}} +{"ts":"2025-08-10T03:23:33.215Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.215Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.217Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.242Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" carved"}}} +{"ts":"2025-08-10T03:23:33.242Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.242Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.244Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.244Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.245Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.269Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" out"}}} +{"ts":"2025-08-10T03:23:33.270Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.270Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.271Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.297Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:33.297Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.297Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.298Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.324Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" obs"}}} +{"ts":"2025-08-10T03:23:33.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.325Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.345Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.346Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.351Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"idian"}}} +{"ts":"2025-08-10T03:23:33.351Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.351Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.352Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.378Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:33.378Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.378Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.379Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.405Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:33.405Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.405Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.406Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.432Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" walls"}}} +{"ts":"2025-08-10T03:23:33.432Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.432Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.433Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.450Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.451Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.459Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pul"}}} +{"ts":"2025-08-10T03:23:33.459Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.459Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.460Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.486Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"sing"}}} +{"ts":"2025-08-10T03:23:33.486Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.486Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.488Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.514Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" faint"}}} +{"ts":"2025-08-10T03:23:33.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.515Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.541Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ly"}}} +{"ts":"2025-08-10T03:23:33.541Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.541Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.542Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.552Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.553Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.568Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:33.568Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.568Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.569Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.595Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:33.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.596Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.622Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:33.622Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.622Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.623Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.649Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"stone"}}} +{"ts":"2025-08-10T03:23:33.649Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.649Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.650Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.653Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.676Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" lay"}}} +{"ts":"2025-08-10T03:23:33.676Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.676Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.677Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.707Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" atop"}}} +{"ts":"2025-08-10T03:23:33.707Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.707Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.708Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.735Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:33.735Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.735Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.736Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.757Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.758Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.762Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" dais"}}} +{"ts":"2025-08-10T03:23:33.762Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.762Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.763Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.789Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:33.789Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.789Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.790Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.815Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" surrounded"}}} +{"ts":"2025-08-10T03:23:33.816Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.816Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.817Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.842Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" by"}}} +{"ts":"2025-08-10T03:23:33.842Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.842Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.844Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.862Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.863Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.869Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" glyph"}}} +{"ts":"2025-08-10T03:23:33.869Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.869Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.871Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.897Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"s"}}} +{"ts":"2025-08-10T03:23:33.897Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.897Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.898Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.924Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:33.924Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.924Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.925Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.951Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" forgotten"}}} +{"ts":"2025-08-10T03:23:33.951Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.951Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.952Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.966Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:33.978Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" run"}}} +{"ts":"2025-08-10T03:23:33.978Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.978Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:33.979Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"es"}}} +{"ts":"2025-08-10T03:23:34.005Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.005Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.007Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.032Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:34.032Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.032Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.033Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.060Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flick"}}} +{"ts":"2025-08-10T03:23:34.060Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.060Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.061Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.067Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.068Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.087Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ered"}}} +{"ts":"2025-08-10T03:23:34.087Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.087Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.088Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.114Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" like"}}} +{"ts":"2025-08-10T03:23:34.114Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.114Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.115Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.141Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" nervous"}}} +{"ts":"2025-08-10T03:23:34.141Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.141Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.142Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.168Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.168Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fire"}}} +{"ts":"2025-08-10T03:23:34.168Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.168Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.170Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.196Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"flies"}}} +{"ts":"2025-08-10T03:23:34.196Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.196Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.197Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.226Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:34.226Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.226Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:34.226Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.228Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.253Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Ca"}}} +{"ts":"2025-08-10T03:23:34.253Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.254Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.255Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.273Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.275Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.281Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:34.281Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.281Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.282Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:34.282Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:34.282Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.282Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.308Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:34.329Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.329Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.330Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.335Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:34.335Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:34.335Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:34.335Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.335Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" heart"}}} +{"ts":"2025-08-10T03:23:34.335Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.335Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.336Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.362Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" hammered"}}} +{"ts":"2025-08-10T03:23:34.378Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.386Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:34.389Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" as"}}} +{"ts":"2025-08-10T03:23:34.417Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" he"}}} +{"ts":"2025-08-10T03:23:34.430Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.430Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.430Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:34.430Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.430Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.430Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.430Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.432Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.444Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" approached"}}} +{"ts":"2025-08-10T03:23:34.444Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.444Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.445Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.471Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:34.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.472Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.483Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.485Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.498Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" He"}}} +{"ts":"2025-08-10T03:23:34.498Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.498Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.500Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.526Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" remembered"}}} +{"ts":"2025-08-10T03:23:34.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.527Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.553Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Mae"}}} +{"ts":"2025-08-10T03:23:34.553Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.553Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.554Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.580Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ve"}}} +{"ts":"2025-08-10T03:23:34.580Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.580Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.581Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.589Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.590Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.607Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:34.607Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.607Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.609Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.634Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" words"}}} +{"ts":"2025-08-10T03:23:34.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.635Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.636Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.662Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":":"}}} +{"ts":"2025-08-10T03:23:34.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.665Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.689Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:34.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.690Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.694Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.695Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.716Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"The"}}} +{"ts":"2025-08-10T03:23:34.716Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.716Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.717Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.745Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fire"}}} +{"ts":"2025-08-10T03:23:34.745Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.745Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.746Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.772Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:34.772Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.772Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.773Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.799Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.799Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ever"}}} +{"ts":"2025-08-10T03:23:34.799Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.799Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.800Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.826Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"night"}}} +{"ts":"2025-08-10T03:23:34.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.827Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.853Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" is"}}} +{"ts":"2025-08-10T03:23:34.853Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.853Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.854Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.880Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" not"}}} +{"ts":"2025-08-10T03:23:34.880Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.880Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.881Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.904Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.905Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.907Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:34.907Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.907Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.908Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.934Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" tool"}}} +{"ts":"2025-08-10T03:23:34.934Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.934Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.935Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.961Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:34.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.962Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:34.988Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" be"}}} +{"ts":"2025-08-10T03:23:34.988Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.988Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:34.990Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.009Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.010Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.016Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" wield"}}} +{"ts":"2025-08-10T03:23:35.016Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.016Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.017Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.042Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ed"}}} +{"ts":"2025-08-10T03:23:35.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.044Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.069Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:35.069Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.069Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.070Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.096Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" but"}}} +{"ts":"2025-08-10T03:23:35.096Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.096Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.097Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.114Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.115Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.123Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:35.123Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.123Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.124Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.150Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" covenant"}}} +{"ts":"2025-08-10T03:23:35.150Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.150Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.151Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.177Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\""}}} +{"ts":"2025-08-10T03:23:35.177Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.177Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.179Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.205Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Yet"}}} +{"ts":"2025-08-10T03:23:35.205Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.205Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.206Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.219Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.220Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.232Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:35.232Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.232Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.233Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.262Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ache"}}} +{"ts":"2025-08-10T03:23:35.262Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.262Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.263Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.289Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:35.289Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.289Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.290Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.316Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" longing"}}} +{"ts":"2025-08-10T03:23:35.316Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.316Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.317Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.320Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.321Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.343Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" burned"}}} +{"ts":"2025-08-10T03:23:35.343Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.343Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.344Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.370Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stronger"}}} +{"ts":"2025-08-10T03:23:35.370Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.370Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.371Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.398Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"—"}}} +{"ts":"2025-08-10T03:23:35.398Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.398Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.399Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.421Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.422Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.425Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"he"}}} +{"ts":"2025-08-10T03:23:35.425Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.425Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.426Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.452Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" wanted"}}} +{"ts":"2025-08-10T03:23:35.452Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.452Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.454Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.479Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:35.479Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.479Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.481Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.507Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:35.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.508Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.527Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.534Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:35.534Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.534Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.535Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.561Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pier"}}} +{"ts":"2025-08-10T03:23:35.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.562Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.588Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ce"}}} +{"ts":"2025-08-10T03:23:35.589Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.589Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.590Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.616Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:35.616Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.616Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.617Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.631Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.632Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.643Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" village"}}} +{"ts":"2025-08-10T03:23:35.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.644Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.670Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:35.670Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.670Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.671Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.697Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" nights"}}} +{"ts":"2025-08-10T03:23:35.697Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.697Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.699Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.725Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:35.725Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.725Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.726Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.735Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.736Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.752Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:35.752Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.752Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.753Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.780Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cure"}}} +{"ts":"2025-08-10T03:23:35.780Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.780Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.781Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.807Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:35.807Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.807Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.808Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.834Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:35.834Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.834Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.835Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.837Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.838Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.861Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"iling"}}} +{"ts":"2025-08-10T03:23:35.861Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.861Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.862Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.888Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" crops"}}} +{"ts":"2025-08-10T03:23:35.888Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.888Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.890Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.916Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:35.916Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.916Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.917Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.942Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.943Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.943Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:35.945Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.945Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.946Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.970Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ease"}}} +{"ts":"2025-08-10T03:23:35.970Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.970Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.971Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:35.998Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:35.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:35.999Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.025Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mother's"}}} +{"ts":"2025-08-10T03:23:36.025Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.025Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.027Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.046Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.052Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" grief"}}} +{"ts":"2025-08-10T03:23:36.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.052Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.053Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.080Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:36.080Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.080Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.081Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.107Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" In"}}} +{"ts":"2025-08-10T03:23:36.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.108Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.134Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:36.134Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.134Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.135Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.147Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.148Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.161Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" moment"}}} +{"ts":"2025-08-10T03:23:36.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.163Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.188Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:36.189Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.189Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.190Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.216Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" felt"}}} +{"ts":"2025-08-10T03:23:36.216Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.216Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.217Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.243Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" like"}}} +{"ts":"2025-08-10T03:23:36.243Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.243Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.244Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.248Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.249Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.270Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:36.270Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.270Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.272Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.297Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" choice"}}} +{"ts":"2025-08-10T03:23:36.297Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.297Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.299Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.324Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" between"}}} +{"ts":"2025-08-10T03:23:36.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.326Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.351Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.352Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" life"}}} +{"ts":"2025-08-10T03:23:36.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.353Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.379Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:36.379Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.379Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.380Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.406Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" death"}}} +{"ts":"2025-08-10T03:23:36.406Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.406Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.407Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.434Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" he"}}} +{"ts":"2025-08-10T03:23:36.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.435Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.452Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.453Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.461Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" placed"}}} +{"ts":"2025-08-10T03:23:36.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.462Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.488Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:36.488Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.488Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.489Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.515Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" palm"}}} +{"ts":"2025-08-10T03:23:36.515Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.516Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.517Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.543Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" upon"}}} +{"ts":"2025-08-10T03:23:36.543Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.543Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.544Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.557Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.558Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.570Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:36.570Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.570Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.571Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.597Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cool"}}} +{"ts":"2025-08-10T03:23:36.597Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.597Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.599Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.625Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:36.625Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.625Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.626Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.652Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:36.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.653Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:36.653Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.654Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.662Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.663Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.679Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Sud"}}} +{"ts":"2025-08-10T03:23:36.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.681Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.707Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"denly"}}} +{"ts":"2025-08-10T03:23:36.707Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.707Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.707Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:36.707Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:36.707Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.708Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.734Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:36.761Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:36.761Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:36.767Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.788Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" voice"}}} +{"ts":"2025-08-10T03:23:36.816Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:36.816Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" reson"}}} +{"ts":"2025-08-10T03:23:36.843Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ated"}}} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.852Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.853Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.868Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:36.868Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.870Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" through"}}} +{"ts":"2025-08-10T03:23:36.898Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:36.925Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" chamber"}}} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.954Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.973Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.975Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:36.980Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" not"}}} +{"ts":"2025-08-10T03:23:36.980Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.980Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:36.981Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.007Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" from"}}} +{"ts":"2025-08-10T03:23:37.007Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.007Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.009Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.035Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" any"}}} +{"ts":"2025-08-10T03:23:37.035Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.035Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.036Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.062Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mortal"}}} +{"ts":"2025-08-10T03:23:37.062Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.062Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.063Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.078Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.080Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.089Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ear"}}} +{"ts":"2025-08-10T03:23:37.089Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.089Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.091Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.117Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:37.117Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.117Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.118Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.144Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" but"}}} +{"ts":"2025-08-10T03:23:37.144Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.144Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.146Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.172Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" from"}}} +{"ts":"2025-08-10T03:23:37.172Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.172Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.173Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.179Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.181Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.199Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:37.199Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.199Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.200Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.226Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" very"}}} +{"ts":"2025-08-10T03:23:37.226Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.226Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.228Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.254Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fire"}}} +{"ts":"2025-08-10T03:23:37.254Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.254Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.255Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.281Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:37.281Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.281Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.282Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.284Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.308Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:37.308Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.308Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.310Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.337Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" kept"}}} +{"ts":"2025-08-10T03:23:37.337Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.337Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.338Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.362Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:37.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.364Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.388Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.389Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" valley"}}} +{"ts":"2025-08-10T03:23:37.389Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.389Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.389Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.416Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" alive"}}} +{"ts":"2025-08-10T03:23:37.416Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.416Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.417Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.443Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":":"}}} +{"ts":"2025-08-10T03:23:37.443Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.443Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.444Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.470Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:37.470Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.470Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.472Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.493Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.494Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.497Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Child"}}} +{"ts":"2025-08-10T03:23:37.497Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.497Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.499Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.525Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:37.525Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.525Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.526Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.552Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:37.552Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.552Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.553Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.579Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:37.579Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.579Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.580Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.598Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.599Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.607Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" you"}}} +{"ts":"2025-08-10T03:23:37.607Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.607Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.608Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.634Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" seek"}}} +{"ts":"2025-08-10T03:23:37.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.635Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.661Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:37.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.663Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.689Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:37.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.690Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.703Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.704Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.716Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cannot"}}} +{"ts":"2025-08-10T03:23:37.716Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.716Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.718Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.744Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" be"}}} +{"ts":"2025-08-10T03:23:37.744Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.744Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.745Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.771Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" taken"}}} +{"ts":"2025-08-10T03:23:37.771Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.771Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.772Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.798Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:37.798Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.798Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.800Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.808Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.809Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.826Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Do"}}} +{"ts":"2025-08-10T03:23:37.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.827Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.853Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" you"}}} +{"ts":"2025-08-10T03:23:37.853Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.853Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.855Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.881Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" dare"}}} +{"ts":"2025-08-10T03:23:37.881Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.881Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.882Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.908Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" become"}}} +{"ts":"2025-08-10T03:23:37.908Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.908Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.909Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.913Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.914Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.935Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:37.935Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.935Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.936Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.962Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flame"}}} +{"ts":"2025-08-10T03:23:37.962Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.962Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.964Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:37.990Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"?\"\n\n"}}} +{"ts":"2025-08-10T03:23:37.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.990Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:37.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:37.991Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.017Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.017Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"From"}}} +{"ts":"2025-08-10T03:23:38.017Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.017Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.018Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.044Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:38.044Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:38.044Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.045Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" behind"}}} +{"ts":"2025-08-10T03:23:38.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.046Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.072Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" him"}}} +{"ts":"2025-08-10T03:23:38.072Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.072Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.073Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.099Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:38.099Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:38.099Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:38.099Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.100Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stepped"}}} +{"ts":"2025-08-10T03:23:38.100Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.100Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.100Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.122Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.127Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:38.154Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:38.154Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" figure"}}} +{"ts":"2025-08-10T03:23:38.164Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.164Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.165Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:38.165Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.165Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.166Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.181Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"—"}}} +{"ts":"2025-08-10T03:23:38.181Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.181Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.183Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.209Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"t"}}} +{"ts":"2025-08-10T03:23:38.209Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.209Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.210Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.227Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.228Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.236Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"all"}}} +{"ts":"2025-08-10T03:23:38.236Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.236Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.238Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.264Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:38.264Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.264Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.265Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.291Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" clo"}}} +{"ts":"2025-08-10T03:23:38.291Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.291Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.293Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.319Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"aked"}}} +{"ts":"2025-08-10T03:23:38.319Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.319Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.320Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.333Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.346Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" in"}}} +{"ts":"2025-08-10T03:23:38.346Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.346Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.347Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.374Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:38.374Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.374Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.375Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.403Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mantle"}}} +{"ts":"2025-08-10T03:23:38.403Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.403Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.405Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.431Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:38.431Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.431Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.433Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.433Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.459Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" darkness"}}} +{"ts":"2025-08-10T03:23:38.459Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.459Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.460Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.486Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:38.486Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.486Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.487Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.514Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" seemed"}}} +{"ts":"2025-08-10T03:23:38.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.515Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.538Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.539Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.541Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:38.541Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.541Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.542Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.568Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" dev"}}} +{"ts":"2025-08-10T03:23:38.568Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.568Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.569Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.595Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"our"}}} +{"ts":"2025-08-10T03:23:38.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.595Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.596Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.623Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" surrounding"}}} +{"ts":"2025-08-10T03:23:38.623Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.623Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.624Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.644Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.650Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:38.650Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.650Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.652Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.678Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:38.678Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.678Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.679Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.705Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:38.705Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.705Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.706Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.733Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" figure"}}} +{"ts":"2025-08-10T03:23:38.733Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.733Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.734Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.747Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.748Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.760Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" revealed"}}} +{"ts":"2025-08-10T03:23:38.760Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.760Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.762Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.788Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" himself"}}} +{"ts":"2025-08-10T03:23:38.788Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.788Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.789Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.815Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" as"}}} +{"ts":"2025-08-10T03:23:38.815Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.815Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.817Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.843Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Az"}}} +{"ts":"2025-08-10T03:23:38.843Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.843Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.844Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.847Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.848Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.870Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"r"}}} +{"ts":"2025-08-10T03:23:38.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.871Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.898Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ion"}}} +{"ts":"2025-08-10T03:23:38.898Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.898Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.899Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.925Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:38.925Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.925Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.927Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.952Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.953Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Keeper"}}} +{"ts":"2025-08-10T03:23:38.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.953Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:38.980Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:38.980Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.980Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:38.982Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.008Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:39.008Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.008Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.009Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.035Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" guardian"}}} +{"ts":"2025-08-10T03:23:39.035Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.035Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.036Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.057Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.058Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.062Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" who"}}} +{"ts":"2025-08-10T03:23:39.062Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.062Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.064Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.090Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:39.090Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.090Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.091Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.117Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sworn"}}} +{"ts":"2025-08-10T03:23:39.117Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.117Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.119Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.145Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:39.145Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.145Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.146Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.162Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.163Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.172Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" oath"}}} +{"ts":"2025-08-10T03:23:39.172Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.172Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.173Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.200Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:39.200Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.200Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.201Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.227Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:39.227Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.227Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.228Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.255Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:39.255Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.255Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.256Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.267Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.268Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.282Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" for"}}} +{"ts":"2025-08-10T03:23:39.282Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.282Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.283Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.310Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" centuries"}}} +{"ts":"2025-08-10T03:23:39.310Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.310Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.311Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.337Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:39.337Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.337Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.338Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.365Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" His"}}} +{"ts":"2025-08-10T03:23:39.365Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.365Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.366Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.368Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.370Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.392Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" eyes"}}} +{"ts":"2025-08-10T03:23:39.392Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.392Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.394Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.420Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" contained"}}} +{"ts":"2025-08-10T03:23:39.420Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.420Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.421Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.450Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" centuries"}}} +{"ts":"2025-08-10T03:23:39.450Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.450Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.452Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.470Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.471Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.478Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:39.478Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.478Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.480Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.506Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sorrow"}}} +{"ts":"2025-08-10T03:23:39.506Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.506Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.507Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.533Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:39.533Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.533Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.535Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.561Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:39.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.562Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.575Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.576Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.589Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"I"}}} +{"ts":"2025-08-10T03:23:39.589Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.589Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.590Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.616Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" see"}}} +{"ts":"2025-08-10T03:23:39.616Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.616Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.617Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.644Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" your"}}} +{"ts":"2025-08-10T03:23:39.644Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.644Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.645Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.671Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" heart"}}} +{"ts":"2025-08-10T03:23:39.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.672Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.675Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.677Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.699Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" is"}}} +{"ts":"2025-08-10T03:23:39.699Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.699Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.700Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.726Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pure"}}} +{"ts":"2025-08-10T03:23:39.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.727Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.753Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":",\""}}} +{"ts":"2025-08-10T03:23:39.753Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.754Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.755Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.778Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.779Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.781Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" he"}}} +{"ts":"2025-08-10T03:23:39.781Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.781Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.782Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.809Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" int"}}} +{"ts":"2025-08-10T03:23:39.809Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.809Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.810Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.836Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"oned"}}} +{"ts":"2025-08-10T03:23:39.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.836Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.864Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:39.864Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.864Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.865Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.879Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.880Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.891Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:39.891Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.891Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.892Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.919Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"But"}}} +{"ts":"2025-08-10T03:23:39.919Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.919Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.920Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.946Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" we"}}} +{"ts":"2025-08-10T03:23:39.946Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.946Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.948Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.974Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" are"}}} +{"ts":"2025-08-10T03:23:39.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.976Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:39.981Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:39.982Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.001Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" bound"}}} +{"ts":"2025-08-10T03:23:40.001Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.001Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.003Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.029Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:40.029Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.029Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.030Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.056Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:40.056Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.056Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.057Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.083Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cycle"}}} +{"ts":"2025-08-10T03:23:40.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.085Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.111Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\"\n\n"}}} +{"ts":"2025-08-10T03:23:40.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.111Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:40.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.113Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.138Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Ca"}}} +{"ts":"2025-08-10T03:23:40.138Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.138Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.139Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.161Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:40.161Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:40.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.163Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.165Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:40.182Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.182Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.183Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.188Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.190Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.192Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" listened"}}} +{"ts":"2025-08-10T03:23:40.192Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.193Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.194Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.216Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:40.216Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:40.216Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:40.216Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.218Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.220Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:40.247Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" heart"}}} +{"ts":"2025-08-10T03:23:40.268Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:40.274Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pounding"}}} +{"ts":"2025-08-10T03:23:40.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.283Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:40.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.285Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.295Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.302Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:40.302Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.302Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.303Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.329Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:40.329Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.329Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.330Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.357Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"I"}}} +{"ts":"2025-08-10T03:23:40.357Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.357Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.358Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.384Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" will"}}} +{"ts":"2025-08-10T03:23:40.384Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.384Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.386Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.398Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.400Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.412Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" spare"}}} +{"ts":"2025-08-10T03:23:40.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.413Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.439Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:40.439Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.439Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.441Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.467Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" village"}}} +{"ts":"2025-08-10T03:23:40.467Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.467Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.468Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.495Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":",\""}}} +{"ts":"2025-08-10T03:23:40.495Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.495Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.496Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.499Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.500Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.529Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" he"}}} +{"ts":"2025-08-10T03:23:40.529Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.529Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.530Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.557Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" said"}}} +{"ts":"2025-08-10T03:23:40.557Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.557Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.558Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.585Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:40.585Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.585Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.586Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.603Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.604Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.612Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" voice"}}} +{"ts":"2025-08-10T03:23:40.612Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.612Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.613Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.640Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" trembling"}}} +{"ts":"2025-08-10T03:23:40.640Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.640Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.641Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.667Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:40.667Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.667Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.668Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.695Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:40.695Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.695Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.696Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.709Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.721Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"But"}}} +{"ts":"2025-08-10T03:23:40.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.723Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.748Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" my"}}} +{"ts":"2025-08-10T03:23:40.748Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.748Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.750Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.775Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mother"}}} +{"ts":"2025-08-10T03:23:40.775Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.775Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.776Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.802Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" suffers"}}} +{"ts":"2025-08-10T03:23:40.802Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.802Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.803Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.811Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.813Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.829Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" from"}}} +{"ts":"2025-08-10T03:23:40.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.830Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.856Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" illness"}}} +{"ts":"2025-08-10T03:23:40.856Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.856Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.857Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.883Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:40.883Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.883Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.884Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.910Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Will"}}} +{"ts":"2025-08-10T03:23:40.910Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.910Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.911Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.916Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.918Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.938Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" you"}}} +{"ts":"2025-08-10T03:23:40.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.939Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.965Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" grant"}}} +{"ts":"2025-08-10T03:23:40.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.966Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:40.993Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" me"}}} +{"ts":"2025-08-10T03:23:40.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:40.994Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.020Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:41.020Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.020Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.021Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.022Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.048Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sl"}}} +{"ts":"2025-08-10T03:23:41.048Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.048Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.050Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.076Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"iver"}}} +{"ts":"2025-08-10T03:23:41.076Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.076Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.077Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.103Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:41.103Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.103Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.104Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.126Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.128Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.131Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fire"}}} +{"ts":"2025-08-10T03:23:41.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.132Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.158Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"?\"\n\n"}}} +{"ts":"2025-08-10T03:23:41.158Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.159Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:41.159Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.160Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.186Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Az"}}} +{"ts":"2025-08-10T03:23:41.186Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.186Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.187Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.213Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:41.213Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:41.213Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.214Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"r"}}} +{"ts":"2025-08-10T03:23:41.214Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.214Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.215Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.232Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.241Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ion"}}} +{"ts":"2025-08-10T03:23:41.268Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:41.269Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sighed"}}} +{"ts":"2025-08-10T03:23:41.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.293Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:41.293Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:41.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.293Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.295Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.296Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:41.323Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:41.324Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:41.337Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.351Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:41.379Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"stone"}}} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.394Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.396Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.406Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:41.406Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.406Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.407Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.434Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" however"}}} +{"ts":"2025-08-10T03:23:41.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.435Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.442Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.443Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.461Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:41.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.463Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.489Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:41.489Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.489Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.490Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.517Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:41.517Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.517Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.518Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.544Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" memory"}}} +{"ts":"2025-08-10T03:23:41.544Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.544Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.545Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.547Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.548Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.571Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:41.571Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.571Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.573Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.605Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" It"}}} +{"ts":"2025-08-10T03:23:41.605Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.605Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.607Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.634Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" remembered"}}} +{"ts":"2025-08-10T03:23:41.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.635Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.653Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.661Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" all"}}} +{"ts":"2025-08-10T03:23:41.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.662Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.689Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:41.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.690Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.717Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:41.717Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.717Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.718Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.744Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" come"}}} +{"ts":"2025-08-10T03:23:41.744Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.744Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.746Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.753Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.754Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.772Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:41.772Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.772Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.773Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.800Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:41.800Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.800Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.801Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.827Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fore"}}} +{"ts":"2025-08-10T03:23:41.827Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.827Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.828Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.855Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:41.855Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.855Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.856Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.858Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.859Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.882Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:41.882Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.882Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.884Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.910Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ancient"}}} +{"ts":"2025-08-10T03:23:41.910Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.910Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.911Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.938Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" run"}}} +{"ts":"2025-08-10T03:23:41.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.939Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.963Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.965Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.965Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"es"}}} +{"ts":"2025-08-10T03:23:41.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.967Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:41.993Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" whispered"}}} +{"ts":"2025-08-10T03:23:41.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:41.994Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.020Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:42.021Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.021Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.022Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.048Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" verdict"}}} +{"ts":"2025-08-10T03:23:42.048Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.048Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.049Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.068Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.070Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.076Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":":"}}} +{"ts":"2025-08-10T03:23:42.076Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.076Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.077Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.104Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:42.104Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.104Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.105Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.131Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:42.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.131Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.133Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.161Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"stone"}}} +{"ts":"2025-08-10T03:23:42.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.162Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.168Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.170Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.188Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" can"}}} +{"ts":"2025-08-10T03:23:42.188Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.188Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.189Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.216Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" grant"}}} +{"ts":"2025-08-10T03:23:42.216Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.216Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.217Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.244Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:42.244Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.244Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.245Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.272Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.272Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" single"}}} +{"ts":"2025-08-10T03:23:42.272Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.272Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.273Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.299Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" boon"}}} +{"ts":"2025-08-10T03:23:42.299Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.299Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.300Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.327Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:42.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.328Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.355Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:42.355Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.355Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.356Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.378Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.383Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pure"}}} +{"ts":"2025-08-10T03:23:42.383Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.383Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.384Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.410Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" heart"}}} +{"ts":"2025-08-10T03:23:42.410Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.410Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.412Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.438Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" for"}}} +{"ts":"2025-08-10T03:23:42.438Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.438Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.439Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.466Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:42.466Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.466Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.467Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.482Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.483Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.493Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" single"}}} +{"ts":"2025-08-10T03:23:42.493Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.493Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.495Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.521Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sacrifice"}}} +{"ts":"2025-08-10T03:23:42.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.522Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.549Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:42.549Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.549Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.550Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.576Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:42.576Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.577Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.578Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.587Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.588Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.604Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" after"}}} +{"ts":"2025-08-10T03:23:42.604Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.604Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.605Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.632Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:42.632Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.632Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.633Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.664Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" act"}}} +{"ts":"2025-08-10T03:23:42.664Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.664Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.666Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.692Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.693Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:42.693Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.693Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.693Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.720Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:42.720Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.720Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.721Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.748Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:42.748Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.748Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.749Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.776Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" would"}}} +{"ts":"2025-08-10T03:23:42.776Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.776Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.777Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.797Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.798Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.803Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" be"}}} +{"ts":"2025-08-10T03:23:42.803Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.803Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.804Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.831Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" forever"}}} +{"ts":"2025-08-10T03:23:42.831Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.831Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.832Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.859Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" extingu"}}} +{"ts":"2025-08-10T03:23:42.859Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.859Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.860Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.886Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ished"}}} +{"ts":"2025-08-10T03:23:42.886Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.886Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.887Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.902Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.903Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.914Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:42.914Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.914Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:42.915Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.916Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.941Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Ca"}}} +{"ts":"2025-08-10T03:23:42.941Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.941Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.942Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.968Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:42.968Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:42.968Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.969Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:42.969Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.969Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:42.970Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:42.997Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" realized"}}} +{"ts":"2025-08-10T03:23:43.007Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.010Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.010Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.011Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.019Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:43.019Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:43.019Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:43.019Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.020Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.025Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:43.052Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" magnitude"}}} +{"ts":"2025-08-10T03:23:43.074Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:43.080Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:43.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.108Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.111Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.113Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.135Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" decision"}}} +{"ts":"2025-08-10T03:23:43.135Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.135Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.136Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.163Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:43.163Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.163Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.164Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.191Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" If"}}} +{"ts":"2025-08-10T03:23:43.191Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.191Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.192Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.212Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.213Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.218Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" he"}}} +{"ts":"2025-08-10T03:23:43.218Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.218Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.220Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.246Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" used"}}} +{"ts":"2025-08-10T03:23:43.246Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.246Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.247Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.274Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:43.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.275Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.302Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fire"}}} +{"ts":"2025-08-10T03:23:43.302Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.302Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.303Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.317Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.318Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.329Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:43.329Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.329Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.331Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.357Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:43.357Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.357Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.358Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.385Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" valley"}}} +{"ts":"2025-08-10T03:23:43.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.386Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.412Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" would"}}} +{"ts":"2025-08-10T03:23:43.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.413Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.414Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.418Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.420Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.440Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" be"}}} +{"ts":"2025-08-10T03:23:43.440Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.440Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.442Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.468Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" plunged"}}} +{"ts":"2025-08-10T03:23:43.468Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.468Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.469Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.496Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" into"}}} +{"ts":"2025-08-10T03:23:43.496Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.496Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.497Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.521Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.524Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" darkness"}}} +{"ts":"2025-08-10T03:23:43.524Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.524Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.525Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.551Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" forever"}}} +{"ts":"2025-08-10T03:23:43.551Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.551Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.553Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.579Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:43.579Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.579Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.580Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.607Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" but"}}} +{"ts":"2025-08-10T03:23:43.607Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.607Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.608Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.625Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.626Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.634Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" perhaps"}}} +{"ts":"2025-08-10T03:23:43.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.635Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.662Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" this"}}} +{"ts":"2025-08-10T03:23:43.662Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.662Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.663Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.690Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" one"}}} +{"ts":"2025-08-10T03:23:43.690Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.690Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.691Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.725Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.726Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.727Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" act"}}} +{"ts":"2025-08-10T03:23:43.727Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.727Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.728Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.755Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" would"}}} +{"ts":"2025-08-10T03:23:43.755Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.755Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.757Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.782Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" save"}}} +{"ts":"2025-08-10T03:23:43.783Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.783Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.784Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.810Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:43.810Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.810Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.811Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.828Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.829Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.838Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" life"}}} +{"ts":"2025-08-10T03:23:43.838Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.838Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.839Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.865Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:43.865Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.866Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.867Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.893Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" He"}}} +{"ts":"2025-08-10T03:23:43.893Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.893Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.895Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.921Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" closed"}}} +{"ts":"2025-08-10T03:23:43.921Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.921Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.922Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.933Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.935Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.949Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:43.949Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.949Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.950Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:43.976Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" eyes"}}} +{"ts":"2025-08-10T03:23:43.976Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.976Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:43.978Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.004Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:44.004Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.004Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.005Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.032Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" taking"}}} +{"ts":"2025-08-10T03:23:44.032Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.032Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.033Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.038Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.040Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.060Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:44.060Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.060Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.061Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.088Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" deep"}}} +{"ts":"2025-08-10T03:23:44.088Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.088Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.089Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.115Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" breath"}}} +{"ts":"2025-08-10T03:23:44.115Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.115Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.117Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.143Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.143Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:44.143Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.143Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.144Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.171Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:44.171Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.171Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.172Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.199Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" placed"}}} +{"ts":"2025-08-10T03:23:44.199Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.199Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.200Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.226Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:44.226Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.226Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.228Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.245Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.246Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.254Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:44.254Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.254Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.255Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.291Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" under"}}} +{"ts":"2025-08-10T03:23:44.291Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.291Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.292Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.320Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:44.320Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.320Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.321Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.348Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.348Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" palm"}}} +{"ts":"2025-08-10T03:23:44.348Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.348Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.349Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.375Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:44.375Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.375Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.376Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.403Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:44.403Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.403Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.404Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.431Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" own"}}} +{"ts":"2025-08-10T03:23:44.431Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.431Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.432Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.453Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.454Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.459Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:44.459Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.459Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.460Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.486Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:44.486Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.486Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.488Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.514Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:44.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.515Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.542Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sparked"}}} +{"ts":"2025-08-10T03:23:44.542Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.542Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.543Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.557Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.570Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:44.570Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.570Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.571Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.597Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:44.597Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.597Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.599Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.625Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" golden"}}} +{"ts":"2025-08-10T03:23:44.625Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.625Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.626Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.652Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" breath"}}} +{"ts":"2025-08-10T03:23:44.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.654Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.662Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.680Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:44.680Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.680Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.681Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.708Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" curled"}}} +{"ts":"2025-08-10T03:23:44.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.709Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.736Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" into"}}} +{"ts":"2025-08-10T03:23:44.736Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.736Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.737Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.763Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.764Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:44.764Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.764Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.765Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.791Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fingers"}}} +{"ts":"2025-08-10T03:23:44.791Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.791Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.793Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.819Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:44.820Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.820Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.821Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.847Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Inst"}}} +{"ts":"2025-08-10T03:23:44.847Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.847Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.848Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.869Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.870Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.875Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"inct"}}} +{"ts":"2025-08-10T03:23:44.875Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.875Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.876Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.902Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ively"}}} +{"ts":"2025-08-10T03:23:44.905Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.905Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.907Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.932Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:44.932Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.932Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.933Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.961Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" he"}}} +{"ts":"2025-08-10T03:23:44.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.962Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.975Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:44.989Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" carried"}}} +{"ts":"2025-08-10T03:23:44.989Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.990Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:44.991Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.018Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:45.018Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.018Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.019Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.046Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" vial"}}} +{"ts":"2025-08-10T03:23:45.046Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.046Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.048Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.075Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:45.075Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.075Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.076Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.080Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.103Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fire"}}} +{"ts":"2025-08-10T03:23:45.103Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.103Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.104Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.132Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:45.132Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.133Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:45.133Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.134Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.160Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"He"}}} +{"ts":"2025-08-10T03:23:45.160Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.160Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.162Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.184Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.185Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.186Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:45.186Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:45.187Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.188Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.189Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" returned"}}} +{"ts":"2025-08-10T03:23:45.217Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:45.230Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.230Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.230Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.230Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.232Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.242Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:45.242Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:45.242Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:45.242Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.243Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.246Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:45.274Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" village"}}} +{"ts":"2025-08-10T03:23:45.288Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.297Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:45.303Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" as"}}} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.333Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.361Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" night"}}} +{"ts":"2025-08-10T03:23:45.361Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.361Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.362Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.389Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.390Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.393Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fell"}}} +{"ts":"2025-08-10T03:23:45.393Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.393Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.394Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.421Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:45.421Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.421Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.423Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.450Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:45.450Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.450Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.451Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.478Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flames"}}} +{"ts":"2025-08-10T03:23:45.479Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.479Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.480Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.494Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.496Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.507Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" were"}}} +{"ts":"2025-08-10T03:23:45.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.509Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.536Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" small"}}} +{"ts":"2025-08-10T03:23:45.536Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.536Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.537Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.566Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:45.566Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.566Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.567Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.594Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" trembling"}}} +{"ts":"2025-08-10T03:23:45.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.596Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.623Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:45.623Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.623Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.624Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.652Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" yet"}}} +{"ts":"2025-08-10T03:23:45.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.652Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.653Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.681Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" they"}}} +{"ts":"2025-08-10T03:23:45.681Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.681Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.682Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.699Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.701Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.710Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" lit"}}} +{"ts":"2025-08-10T03:23:45.710Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.710Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.711Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.738Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:45.738Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.738Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.739Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.766Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" wounds"}}} +{"ts":"2025-08-10T03:23:45.766Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.766Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.767Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.794Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:45.794Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.794Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.795Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.804Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.806Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.822Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:45.822Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.822Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.823Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.850Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mother's"}}} +{"ts":"2025-08-10T03:23:45.850Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.850Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.851Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.878Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fever"}}} +{"ts":"2025-08-10T03:23:45.878Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.878Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.879Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.906Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:45.906Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.906Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.907Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.908Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.909Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.934Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Her"}}} +{"ts":"2025-08-10T03:23:45.934Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.934Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.936Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.961Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" eyes"}}} +{"ts":"2025-08-10T03:23:45.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.963Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:45.989Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" widened"}}} +{"ts":"2025-08-10T03:23:45.989Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.989Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:45.991Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.013Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.014Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.018Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" as"}}} +{"ts":"2025-08-10T03:23:46.018Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.018Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.019Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.045Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" warmth"}}} +{"ts":"2025-08-10T03:23:46.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.047Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.073Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flooded"}}} +{"ts":"2025-08-10T03:23:46.073Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.073Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.074Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.101Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" through"}}} +{"ts":"2025-08-10T03:23:46.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.102Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.118Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.119Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.129Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" her"}}} +{"ts":"2025-08-10T03:23:46.129Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.129Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.130Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.157Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" bones"}}} +{"ts":"2025-08-10T03:23:46.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.158Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.185Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:46.185Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.185Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.186Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.213Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" She"}}} +{"ts":"2025-08-10T03:23:46.213Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.213Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.214Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.224Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.241Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" smiled"}}} +{"ts":"2025-08-10T03:23:46.241Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.241Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.242Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.269Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:46.269Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.269Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.270Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.296Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:46.296Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.296Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.298Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.324Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" small"}}} +{"ts":"2025-08-10T03:23:46.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.325Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.328Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.329Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.352Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" but"}}} +{"ts":"2025-08-10T03:23:46.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.353Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.380Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sincere"}}} +{"ts":"2025-08-10T03:23:46.380Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.380Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.381Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.408Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" expression"}}} +{"ts":"2025-08-10T03:23:46.408Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.408Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.409Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.428Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.430Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.436Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:46.436Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.436Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.437Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.464Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" gratitude"}}} +{"ts":"2025-08-10T03:23:46.464Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.464Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.465Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.494Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:46.494Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.494Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.496Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.523Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:46.523Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.523Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.524Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.530Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.532Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.551Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"My"}}} +{"ts":"2025-08-10T03:23:46.551Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.551Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.552Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.579Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" love"}}} +{"ts":"2025-08-10T03:23:46.579Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.579Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.580Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.607Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":",\""}}} +{"ts":"2025-08-10T03:23:46.607Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.607Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.608Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.631Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.632Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.635Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" she"}}} +{"ts":"2025-08-10T03:23:46.635Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.635Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.636Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.663Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" whispered"}}} +{"ts":"2025-08-10T03:23:46.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.663Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.664Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.691Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:46.691Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.691Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.692Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.719Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" \""}}} +{"ts":"2025-08-10T03:23:46.719Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.719Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.720Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.735Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.736Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.746Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"you"}}} +{"ts":"2025-08-10T03:23:46.746Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.747Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.748Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.774Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" have"}}} +{"ts":"2025-08-10T03:23:46.774Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.774Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.776Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.802Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" saved"}}} +{"ts":"2025-08-10T03:23:46.802Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.802Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.804Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.830Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" me"}}} +{"ts":"2025-08-10T03:23:46.830Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.830Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.832Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.835Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.837Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.858Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:46.858Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.858Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.860Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.886Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" I"}}} +{"ts":"2025-08-10T03:23:46.886Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.886Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.888Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.914Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cannot"}}} +{"ts":"2025-08-10T03:23:46.914Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.914Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.916Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.936Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.938Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.943Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" say"}}} +{"ts":"2025-08-10T03:23:46.943Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.943Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.944Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.971Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" I"}}} +{"ts":"2025-08-10T03:23:46.971Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.971Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.972Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:46.999Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" understand"}}} +{"ts":"2025-08-10T03:23:46.999Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:46.999Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.000Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.026Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" how"}}} +{"ts":"2025-08-10T03:23:47.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.028Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.042Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.054Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" you'd"}}} +{"ts":"2025-08-10T03:23:47.054Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.054Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.056Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.082Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" choose"}}} +{"ts":"2025-08-10T03:23:47.082Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.082Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.084Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.110Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" our"}}} +{"ts":"2025-08-10T03:23:47.110Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.110Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.111Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.138Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" safety"}}} +{"ts":"2025-08-10T03:23:47.138Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.138Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.139Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.145Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.147Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.166Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" for"}}} +{"ts":"2025-08-10T03:23:47.166Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.166Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.167Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.194Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:47.194Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.194Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.195Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.222Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" single"}}} +{"ts":"2025-08-10T03:23:47.222Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.222Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.223Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.248Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.249Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.250Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flame"}}} +{"ts":"2025-08-10T03:23:47.250Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.250Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.251Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.278Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"...\"\n\n"}}} +{"ts":"2025-08-10T03:23:47.278Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.279Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:47.279Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.280Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.306Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"After"}}} +{"ts":"2025-08-10T03:23:47.306Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.306Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.307Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.331Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:47.331Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:47.331Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.332Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.334Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:47.350Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.350Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.351Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.353Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.362Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" night"}}} +{"ts":"2025-08-10T03:23:47.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.363Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.386Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:47.386Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:47.386Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:47.386Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.387Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.390Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:47.418Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ca"}}} +{"ts":"2025-08-10T03:23:47.441Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:47.446Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:47.451Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.451Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.451Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.451Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.451Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:47.451Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.451Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.453Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.457Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.458Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.473Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sealed"}}} +{"ts":"2025-08-10T03:23:47.473Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.473Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.474Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.505Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:47.505Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.505Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.506Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.533Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:47.533Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.533Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.534Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.561Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"stone"}}} +{"ts":"2025-08-10T03:23:47.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.561Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.562Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.563Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.589Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" back"}}} +{"ts":"2025-08-10T03:23:47.589Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.589Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.591Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.617Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" in"}}} +{"ts":"2025-08-10T03:23:47.617Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.617Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.619Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.645Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:47.645Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.645Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.647Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.664Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.666Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.673Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sanctuary"}}} +{"ts":"2025-08-10T03:23:47.673Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.673Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.675Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.701Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:47.701Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.701Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.703Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.730Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" took"}}} +{"ts":"2025-08-10T03:23:47.730Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.730Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.731Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.758Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:47.758Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.758Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.759Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.769Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.771Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.786Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" responsibility"}}} +{"ts":"2025-08-10T03:23:47.786Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.786Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.787Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.814Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:47.814Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.814Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.815Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.842Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" keeping"}}} +{"ts":"2025-08-10T03:23:47.842Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.842Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.843Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.870Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:47.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.871Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.871Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.873Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.898Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:47.898Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.898Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.899Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.926Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:47.926Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.926Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.927Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.954Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:47.954Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.954Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.955Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.976Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.978Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:47.982Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" keep"}}} +{"ts":"2025-08-10T03:23:47.982Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.982Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:47.984Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.010Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:48.010Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.010Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.012Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.040Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Though"}}} +{"ts":"2025-08-10T03:23:48.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.041Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.068Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:48.068Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.068Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.069Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.081Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.083Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.096Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" valley"}}} +{"ts":"2025-08-10T03:23:48.096Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.096Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.097Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.124Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" was"}}} +{"ts":"2025-08-10T03:23:48.124Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.124Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.125Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.152Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" robbed"}}} +{"ts":"2025-08-10T03:23:48.152Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.152Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.153Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.180Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:48.180Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.180Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.181Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.182Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.208Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:48.208Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.208Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.210Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.236Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" eternal"}}} +{"ts":"2025-08-10T03:23:48.236Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.236Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.237Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.264Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:48.264Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.264Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.266Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.285Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.287Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.292Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:48.292Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.292Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.294Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.320Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:48.321Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.321Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.322Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.349Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" memory"}}} +{"ts":"2025-08-10T03:23:48.349Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.349Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.350Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.377Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" lasted"}}} +{"ts":"2025-08-10T03:23:48.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.378Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.389Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.391Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.405Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:48.405Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.405Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.406Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.433Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" His"}}} +{"ts":"2025-08-10T03:23:48.433Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.433Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.434Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.461Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mother"}}} +{"ts":"2025-08-10T03:23:48.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.461Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.462Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.489Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:48.489Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.489Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.490Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.490Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.521Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" healed"}}} +{"ts":"2025-08-10T03:23:48.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.521Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.522Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.550Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:48.550Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.550Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.551Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.577Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" lived"}}} +{"ts":"2025-08-10T03:23:48.577Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.577Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.579Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.591Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.593Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.605Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" many"}}} +{"ts":"2025-08-10T03:23:48.605Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.605Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.607Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.634Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" more"}}} +{"ts":"2025-08-10T03:23:48.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.634Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.635Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.661Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" years"}}} +{"ts":"2025-08-10T03:23:48.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.661Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.663Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.689Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:48.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.689Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.690Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.692Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.693Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.717Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:48.717Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.717Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.718Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.745Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" village"}}} +{"ts":"2025-08-10T03:23:48.745Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.745Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.746Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.773Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" adapted"}}} +{"ts":"2025-08-10T03:23:48.773Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.773Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.774Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.793Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.794Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.801Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:48.801Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.801Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.803Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.829Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" learning"}}} +{"ts":"2025-08-10T03:23:48.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.829Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.831Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.857Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:48.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.857Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.859Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.885Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" find"}}} +{"ts":"2025-08-10T03:23:48.885Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.885Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.887Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.898Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.899Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.914Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" joy"}}} +{"ts":"2025-08-10T03:23:48.914Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.914Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.915Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.942Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" in"}}} +{"ts":"2025-08-10T03:23:48.942Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.942Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.943Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.970Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:48.970Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.970Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.971Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:48.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.998Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flick"}}} +{"ts":"2025-08-10T03:23:48.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:48.999Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.026Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"er"}}} +{"ts":"2025-08-10T03:23:49.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.027Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.057Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:49.057Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.057Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.059Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.086Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:49.086Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.086Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.087Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.100Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.102Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.114Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" single"}}} +{"ts":"2025-08-10T03:23:49.114Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.114Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.116Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.143Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" candle"}}} +{"ts":"2025-08-10T03:23:49.143Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.143Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.144Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.171Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" each"}}} +{"ts":"2025-08-10T03:23:49.171Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.171Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.172Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.199Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" night"}}} +{"ts":"2025-08-10T03:23:49.199Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.199Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.200Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.201Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.202Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.227Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:49.227Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.228Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:49.228Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.229Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.255Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Years"}}} +{"ts":"2025-08-10T03:23:49.255Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.255Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.257Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.283Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:49.283Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:49.283Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.284Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" turned"}}} +{"ts":"2025-08-10T03:23:49.284Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.284Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.284Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.302Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.312Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" into"}}} +{"ts":"2025-08-10T03:23:49.338Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:49.340Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" decades"}}} +{"ts":"2025-08-10T03:23:49.368Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:49.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.369Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:49.369Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:49.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.370Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.370Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.370Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.370Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.371Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.393Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:49.396Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:49.407Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.424Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" then"}}} +{"ts":"2025-08-10T03:23:49.452Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" centuries"}}} +{"ts":"2025-08-10T03:23:49.471Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:49.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.472Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.480Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:49.481Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.481Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.482Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.509Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.509Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" until"}}} +{"ts":"2025-08-10T03:23:49.509Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.509Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.510Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.537Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:49.537Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.537Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.538Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.565Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Great"}}} +{"ts":"2025-08-10T03:23:49.565Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.565Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.566Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.593Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Storm"}}} +{"ts":"2025-08-10T03:23:49.593Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.593Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.594Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.614Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.615Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.629Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" swept"}}} +{"ts":"2025-08-10T03:23:49.629Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.629Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.630Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.658Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" across"}}} +{"ts":"2025-08-10T03:23:49.658Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.658Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.659Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.686Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ever"}}} +{"ts":"2025-08-10T03:23:49.686Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.686Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.687Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.714Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"g"}}} +{"ts":"2025-08-10T03:23:49.714Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.714Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.715Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.719Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.720Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.742Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"len"}}} +{"ts":"2025-08-10T03:23:49.742Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.742Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.743Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.770Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:49.770Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.770Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.771Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.798Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" breaking"}}} +{"ts":"2025-08-10T03:23:49.798Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.798Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.799Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.824Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.825Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.826Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:49.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.826Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.827Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.854Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" veil"}}} +{"ts":"2025-08-10T03:23:49.854Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.854Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.855Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.882Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:49.882Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.882Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.883Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.910Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:49.910Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.910Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.911Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.929Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.930Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.938Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" once"}}} +{"ts":"2025-08-10T03:23:49.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.938Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.940Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.966Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" un"}}} +{"ts":"2025-08-10T03:23:49.966Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.966Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.968Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:49.994Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"break"}}} +{"ts":"2025-08-10T03:23:49.994Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.995Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:49.996Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.023Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"able"}}} +{"ts":"2025-08-10T03:23:50.023Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.023Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.024Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.034Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.035Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.051Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:50.051Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.051Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.052Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.079Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" walls"}}} +{"ts":"2025-08-10T03:23:50.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.079Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.080Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.107Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:50.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.109Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.135Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:50.135Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.135Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.136Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.139Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.140Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.164Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" storm"}}} +{"ts":"2025-08-10T03:23:50.164Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.164Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.166Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.192Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" tore"}}} +{"ts":"2025-08-10T03:23:50.192Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.192Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.193Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.220Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:50.220Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.220Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.221Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.244Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.245Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.248Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Keep"}}} +{"ts":"2025-08-10T03:23:50.248Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.248Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.250Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.277Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:50.277Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.277Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.278Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.305Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" L"}}} +{"ts":"2025-08-10T03:23:50.305Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.305Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.306Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.333Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"iora"}}} +{"ts":"2025-08-10T03:23:50.333Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.333Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.334Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.347Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.348Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.361Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" into"}}} +{"ts":"2025-08-10T03:23:50.361Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.361Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.363Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.390Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ruins"}}} +{"ts":"2025-08-10T03:23:50.390Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.390Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.391Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.418Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:50.418Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.418Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.419Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.446Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" leaving"}}} +{"ts":"2025-08-10T03:23:50.446Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.446Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.447Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.448Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.449Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.474Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:50.474Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.474Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.475Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.502Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:50.502Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.502Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.503Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.530Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"stone"}}} +{"ts":"2025-08-10T03:23:50.530Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.530Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.531Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.552Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.553Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.558Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" un"}}} +{"ts":"2025-08-10T03:23:50.558Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.558Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.560Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.587Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"protected"}}} +{"ts":"2025-08-10T03:23:50.587Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.587Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.588Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.615Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:50.615Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.615Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.616Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.643Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" In"}}} +{"ts":"2025-08-10T03:23:50.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.644Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.657Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.658Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.671Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:50.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.672Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.699Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ruins"}}} +{"ts":"2025-08-10T03:23:50.699Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.699Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.700Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.736Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:50.736Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.736Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.737Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.762Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.764Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.767Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" whispers"}}} +{"ts":"2025-08-10T03:23:50.767Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.768Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.769Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.796Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" spread"}}} +{"ts":"2025-08-10T03:23:50.796Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.796Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.797Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.824Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:50.824Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.824Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.825Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.853Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" someone"}}} +{"ts":"2025-08-10T03:23:50.854Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.854Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.856Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.865Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.881Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" from"}}} +{"ts":"2025-08-10T03:23:50.881Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.881Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.882Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.909Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:50.909Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.909Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.910Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.937Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" past"}}} +{"ts":"2025-08-10T03:23:50.937Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.937Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.938Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.964Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.965Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:50.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.965Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.966Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:50.993Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stolen"}}} +{"ts":"2025-08-10T03:23:50.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.993Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:50.994Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.021Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:51.021Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.021Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.022Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.049Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:51.049Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.049Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.050Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.069Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.071Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.077Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:51.077Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.077Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.078Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.105Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" or"}}} +{"ts":"2025-08-10T03:23:51.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.107Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.133Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:51.133Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.133Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.135Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.162Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" it"}}} +{"ts":"2025-08-10T03:23:51.162Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.162Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.163Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.171Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.172Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.190Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:51.190Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.190Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.191Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.218Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" been"}}} +{"ts":"2025-08-10T03:23:51.218Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.218Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.219Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.246Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" left"}}} +{"ts":"2025-08-10T03:23:51.246Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.246Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.248Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.275Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" behind"}}} +{"ts":"2025-08-10T03:23:51.275Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.275Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.276Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.303Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:51.303Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.304Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:51.304Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.305Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.330Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"It"}}} +{"ts":"2025-08-10T03:23:51.330Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.330Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.332Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.359Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" was"}}} +{"ts":"2025-08-10T03:23:51.359Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.359Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.359Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:51.359Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:51.359Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.360Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.379Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.387Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" then"}}} +{"ts":"2025-08-10T03:23:51.389Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.389Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.390Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.414Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:51.414Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:51.414Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:51.414Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.415Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.415Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:51.443Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:51.469Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:51.472Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" new"}}} +{"ts":"2025-08-10T03:23:51.484Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.490Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.490Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.490Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.490Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.490Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:51.490Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.490Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.492Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.500Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" chapter"}}} +{"ts":"2025-08-10T03:23:51.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.501Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.528Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" began"}}} +{"ts":"2025-08-10T03:23:51.528Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.528Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.530Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.556Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:51.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.558Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.584Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" A"}}} +{"ts":"2025-08-10T03:23:51.584Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.584Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.585Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.586Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.587Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.612Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" young"}}} +{"ts":"2025-08-10T03:23:51.612Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.612Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.614Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.641Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" woman"}}} +{"ts":"2025-08-10T03:23:51.641Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.641Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.642Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.669Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" named"}}} +{"ts":"2025-08-10T03:23:51.669Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.669Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.670Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.691Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.692Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.697Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ar"}}} +{"ts":"2025-08-10T03:23:51.697Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.697Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.698Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.725Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ia"}}} +{"ts":"2025-08-10T03:23:51.725Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.725Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.727Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.754Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:51.754Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.754Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.755Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.782Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" descendant"}}} +{"ts":"2025-08-10T03:23:51.782Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.782Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.783Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.796Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.797Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.810Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:51.810Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.810Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.811Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.843Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ca"}}} +{"ts":"2025-08-10T03:23:51.843Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.843Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.844Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.872Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:51.872Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.872Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.873Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.900Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:51.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.901Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.929Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" line"}}} +{"ts":"2025-08-10T03:23:51.929Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.929Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.930Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.957Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:51.957Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.957Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.958Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:51.985Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" with"}}} +{"ts":"2025-08-10T03:23:51.985Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.985Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:51.987Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.002Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.003Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.014Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:52.014Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.014Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.015Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.042Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" keen"}}} +{"ts":"2025-08-10T03:23:52.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.043Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.070Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" curiosity"}}} +{"ts":"2025-08-10T03:23:52.070Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.070Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.071Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.098Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:52.098Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.098Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.099Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.107Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.108Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.127Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" an"}}} +{"ts":"2025-08-10T03:23:52.127Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.127Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.128Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.155Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" un"}}} +{"ts":"2025-08-10T03:23:52.155Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.155Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.156Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.183Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"fl"}}} +{"ts":"2025-08-10T03:23:52.183Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.183Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.184Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.211Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.212Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"in"}}} +{"ts":"2025-08-10T03:23:52.212Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.212Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.212Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.239Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ching"}}} +{"ts":"2025-08-10T03:23:52.240Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.240Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.241Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.268Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" resolve"}}} +{"ts":"2025-08-10T03:23:52.268Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.268Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.269Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.296Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:52.296Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.296Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.297Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.316Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.318Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.324Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" would"}}} +{"ts":"2025-08-10T03:23:52.325Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.325Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.326Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.353Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" find"}}} +{"ts":"2025-08-10T03:23:52.353Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.353Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.354Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.381Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" her"}}} +{"ts":"2025-08-10T03:23:52.381Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.381Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.384Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.411Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" way"}}} +{"ts":"2025-08-10T03:23:52.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.411Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.412Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.420Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.421Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.439Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:52.439Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.439Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.440Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.467Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" She"}}} +{"ts":"2025-08-10T03:23:52.467Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.467Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.468Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.495Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" would"}}} +{"ts":"2025-08-10T03:23:52.495Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.495Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.496Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.524Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" hear"}}} +{"ts":"2025-08-10T03:23:52.524Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.524Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.525Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.525Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.552Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:52.552Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.552Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.553Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.580Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:52.580Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.580Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.582Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.609Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" old"}}} +{"ts":"2025-08-10T03:23:52.609Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.609Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.610Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.630Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.631Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.637Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stories"}}} +{"ts":"2025-08-10T03:23:52.637Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.637Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.638Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.665Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:52.665Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.665Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.667Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.694Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:52.694Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.694Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.695Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.722Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stone"}}} +{"ts":"2025-08-10T03:23:52.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.722Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.723Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.735Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.736Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.750Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:52.750Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.750Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.751Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.778Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:52.778Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.778Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.780Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.807Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:52.807Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.807Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.808Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.835Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:52.835Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.835Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.835Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.836Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.863Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" memory"}}} +{"ts":"2025-08-10T03:23:52.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.863Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.864Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.892Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:52.892Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.892Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.893Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.920Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" carried"}}} +{"ts":"2025-08-10T03:23:52.920Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.920Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.921Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.940Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.942Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.952Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:52.952Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.952Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.953Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:52.981Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" weight"}}} +{"ts":"2025-08-10T03:23:52.981Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.981Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:52.982Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.009Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:53.009Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.009Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.011Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.038Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" choice"}}} +{"ts":"2025-08-10T03:23:53.038Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.038Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.039Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.047Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.066Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:53.066Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.067Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:53.067Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.069Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.094Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Ar"}}} +{"ts":"2025-08-10T03:23:53.094Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.094Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.095Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.119Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:53.119Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:53.119Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.120Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.122Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ia"}}} +{"ts":"2025-08-10T03:23:53.148Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.150Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" walked"}}} +{"ts":"2025-08-10T03:23:53.169Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:53.179Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" along"}}} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.206Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.207Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:53.207Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.207Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.208Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.224Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:53.236Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" river"}}} +{"ts":"2025-08-10T03:23:53.252Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.264Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:53.292Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" found"}}} +{"ts":"2025-08-10T03:23:53.307Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:53.307Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.307Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.307Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.307Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.307Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.307Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.308Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.321Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" herself"}}} +{"ts":"2025-08-10T03:23:53.321Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.321Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.322Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.349Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" at"}}} +{"ts":"2025-08-10T03:23:53.349Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.349Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.350Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.357Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.358Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.377Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:53.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.379Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.406Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ruins"}}} +{"ts":"2025-08-10T03:23:53.406Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.406Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.407Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.434Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:53.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.436Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.462Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.462Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" She"}}} +{"ts":"2025-08-10T03:23:53.462Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.462Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.463Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.491Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" felt"}}} +{"ts":"2025-08-10T03:23:53.491Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.491Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.492Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:53.519Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.519Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.520Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.547Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pull"}}} +{"ts":"2025-08-10T03:23:53.547Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.547Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.548Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.564Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.565Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.575Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" in"}}} +{"ts":"2025-08-10T03:23:53.575Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.575Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.576Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.604Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" her"}}} +{"ts":"2025-08-10T03:23:53.604Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.604Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.605Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.632Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" heart"}}} +{"ts":"2025-08-10T03:23:53.632Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.632Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.633Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.660Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:53.660Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.660Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.661Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.669Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.670Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.688Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" felt"}}} +{"ts":"2025-08-10T03:23:53.688Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.688Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.689Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.716Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:53.716Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.716Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.717Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.743Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stirring"}}} +{"ts":"2025-08-10T03:23:53.743Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.743Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.745Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.771Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.771Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:53.771Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.771Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.772Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.799Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" destiny"}}} +{"ts":"2025-08-10T03:23:53.799Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.799Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.800Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.827Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:53.827Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.827Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.828Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.855Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" She"}}} +{"ts":"2025-08-10T03:23:53.855Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.856Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.857Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.876Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.877Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.884Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" discovered"}}} +{"ts":"2025-08-10T03:23:53.884Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.884Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.885Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.912Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:53.912Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.912Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.913Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.941Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" in"}}} +{"ts":"2025-08-10T03:23:53.941Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.941Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.942Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.969Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:53.969Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.969Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.971Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.981Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.982Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:53.998Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" middle"}}} +{"ts":"2025-08-10T03:23:53.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:53.999Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.035Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:54.035Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.035Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.036Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.065Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:54.065Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.065Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.066Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.085Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.087Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.093Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ruins"}}} +{"ts":"2025-08-10T03:23:54.093Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.093Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.094Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.121Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:54.121Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.122Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.123Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.150Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:54.150Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.150Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.151Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.178Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cavity"}}} +{"ts":"2025-08-10T03:23:54.178Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.178Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.180Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.185Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.187Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.207Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:54.207Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.207Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.208Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.235Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" gl"}}} +{"ts":"2025-08-10T03:23:54.235Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.235Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.236Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.264Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"owed"}}} +{"ts":"2025-08-10T03:23:54.264Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.264Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.265Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.290Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.292Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.292Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" faint"}}} +{"ts":"2025-08-10T03:23:54.292Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.292Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.293Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.320Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ly"}}} +{"ts":"2025-08-10T03:23:54.320Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.320Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.322Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.349Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" in"}}} +{"ts":"2025-08-10T03:23:54.349Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.349Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.350Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.377Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:54.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.377Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.378Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.395Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.397Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.405Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" moon"}}} +{"ts":"2025-08-10T03:23:54.405Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.405Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.407Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.434Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"light"}}} +{"ts":"2025-08-10T03:23:54.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.434Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.435Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.463Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":":"}}} +{"ts":"2025-08-10T03:23:54.463Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.463Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.464Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.491Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:54.491Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.491Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.492Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.499Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.500Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.520Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ember"}}} +{"ts":"2025-08-10T03:23:54.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.521Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.548Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"stone"}}} +{"ts":"2025-08-10T03:23:54.548Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.548Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.549Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.576Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:54.576Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.576Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.577Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.604Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.605Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.614Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" broken"}}} +{"ts":"2025-08-10T03:23:54.614Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.614Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.615Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.643Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:54.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.643Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.644Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.672Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" yet"}}} +{"ts":"2025-08-10T03:23:54.672Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.672Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.673Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.700Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" still"}}} +{"ts":"2025-08-10T03:23:54.700Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.700Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.701Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.709Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.710Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.728Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" lit"}}} +{"ts":"2025-08-10T03:23:54.728Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.728Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.730Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.757Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:54.757Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.757Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.758Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.785Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:54.785Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.785Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.787Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.814Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.814Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" same"}}} +{"ts":"2025-08-10T03:23:54.814Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.814Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.815Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.842Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:54.842Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.842Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.844Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.871Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:54.871Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.871Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.872Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.899Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:54.899Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.899Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.901Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.919Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.920Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.928Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" lived"}}} +{"ts":"2025-08-10T03:23:54.928Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.928Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.929Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.956Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" on"}}} +{"ts":"2025-08-10T03:23:54.956Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.956Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.958Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:54.985Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ca"}}} +{"ts":"2025-08-10T03:23:54.985Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.985Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:54.986Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.013Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:55.014Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.014Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.015Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.024Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.025Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.042Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:55.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.043Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.070Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" finger"}}} +{"ts":"2025-08-10T03:23:55.070Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.070Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.072Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.099Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:55.099Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.100Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:55.100Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.101Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.127Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.127Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"With"}}} +{"ts":"2025-08-10T03:23:55.127Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.127Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.128Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.152Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:55.152Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:55.152Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.153Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.156Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" careful"}}} +{"ts":"2025-08-10T03:23:55.184Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" hands"}}} +{"ts":"2025-08-10T03:23:55.207Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:55.213Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.223Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.225Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.232Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.241Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" she"}}} +{"ts":"2025-08-10T03:23:55.262Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:55.270Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" reached"}}} +{"ts":"2025-08-10T03:23:55.298Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" for"}}} +{"ts":"2025-08-10T03:23:55.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.324Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:55.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.325Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.325Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.325Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.326Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.327Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" it"}}} +{"ts":"2025-08-10T03:23:55.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.327Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.328Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.336Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.337Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.355Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:55.355Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.355Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.356Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.383Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" It"}}} +{"ts":"2025-08-10T03:23:55.383Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.383Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.385Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.412Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" was"}}} +{"ts":"2025-08-10T03:23:55.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.412Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.413Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.441Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" warm"}}} +{"ts":"2025-08-10T03:23:55.441Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.441Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.441Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.442Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.469Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:55.469Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.469Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.470Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.498Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:55.498Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.498Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.499Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.526Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" when"}}} +{"ts":"2025-08-10T03:23:55.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.526Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.527Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.546Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.547Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.554Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" she"}}} +{"ts":"2025-08-10T03:23:55.554Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.554Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.555Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.583Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" embraced"}}} +{"ts":"2025-08-10T03:23:55.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.584Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.611Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" it"}}} +{"ts":"2025-08-10T03:23:55.611Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.611Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.612Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.640Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:55.640Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.640Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.641Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.651Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.652Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.668Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" she"}}} +{"ts":"2025-08-10T03:23:55.668Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.668Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.670Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.703Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" felt"}}} +{"ts":"2025-08-10T03:23:55.703Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.703Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.705Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.733Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:55.733Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.733Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.734Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.756Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.757Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.761Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" vision"}}} +{"ts":"2025-08-10T03:23:55.761Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.761Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.762Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.790Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":":"}}} +{"ts":"2025-08-10T03:23:55.790Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.790Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.791Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.818Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:55.818Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.818Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.819Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.847Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" valley"}}} +{"ts":"2025-08-10T03:23:55.847Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.847Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.848Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.861Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.862Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.875Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" under"}}} +{"ts":"2025-08-10T03:23:55.875Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.875Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.876Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.904Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:55.904Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.904Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.905Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.932Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" storm"}}} +{"ts":"2025-08-10T03:23:55.932Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.932Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.934Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.961Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:55.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.961Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.962Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:55.989Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:55.989Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.989Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:55.990Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.017Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:56.017Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.017Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.018Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.045Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:56.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.045Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.046Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.066Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.067Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.073Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:56.073Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.073Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.075Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.102Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:56.102Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.102Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.103Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.130Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:56.130Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.130Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.131Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.158Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ca"}}} +{"ts":"2025-08-10T03:23:56.158Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.158Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.159Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.169Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.170Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.186Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:56.186Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.186Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.187Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.215Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:56.215Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.215Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.216Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.243Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" smile"}}} +{"ts":"2025-08-10T03:23:56.243Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.243Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.245Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.271Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:56.271Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.271Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.273Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.275Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.299Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:56.300Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.300Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.301Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.328Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" hope"}}} +{"ts":"2025-08-10T03:23:56.328Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.328Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.329Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.356Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" he"}}} +{"ts":"2025-08-10T03:23:56.356Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.356Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.358Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.379Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.380Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.385Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" brought"}}} +{"ts":"2025-08-10T03:23:56.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.386Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.413Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:56.413Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.413Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.415Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.442Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" his"}}} +{"ts":"2025-08-10T03:23:56.442Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.442Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.443Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.471Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" mother"}}} +{"ts":"2025-08-10T03:23:56.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.472Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.484Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.485Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.499Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:56.499Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.499Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.500Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.528Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" She"}}} +{"ts":"2025-08-10T03:23:56.528Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.528Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.529Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.556Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" understood"}}} +{"ts":"2025-08-10T03:23:56.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.557Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.585Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.585Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" then"}}} +{"ts":"2025-08-10T03:23:56.585Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.585Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.586Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.613Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:56.613Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.613Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.615Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.642Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" weight"}}} +{"ts":"2025-08-10T03:23:56.642Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.642Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.643Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.671Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:56.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.672Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.690Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.691Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.699Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" decisions"}}} +{"ts":"2025-08-10T03:23:56.699Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.699Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.700Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.727Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:56.727Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.727Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.728Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.756Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:56.756Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.756Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.757Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.788Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" price"}}} +{"ts":"2025-08-10T03:23:56.788Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.788Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.790Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.790Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.791Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.818Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" paid"}}} +{"ts":"2025-08-10T03:23:56.818Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.818Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.819Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.846Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:56.846Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.847Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:56.847Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.849Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.875Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"Ar"}}} +{"ts":"2025-08-10T03:23:56.875Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.875Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.876Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.895Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.896Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.902Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:56.902Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:56.902Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.903Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ia"}}} +{"ts":"2025-08-10T03:23:56.903Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.903Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.904Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.931Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" understood"}}} +{"ts":"2025-08-10T03:23:56.939Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.939Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.940Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.957Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:56.957Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:56.957Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:56.957Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:56.958Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:56.960Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:56.988Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" she"}}} +{"ts":"2025-08-10T03:23:57.000Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.012Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:57.016Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" must"}}} +{"ts":"2025-08-10T03:23:57.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.040Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:57.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.040Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.041Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.044Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" protect"}}} +{"ts":"2025-08-10T03:23:57.044Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.044Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.046Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.073Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:57.073Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.073Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.074Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.101Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:57.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.102Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.102Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.104Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.129Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:57.129Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.129Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.130Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.157Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" as"}}} +{"ts":"2025-08-10T03:23:57.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.157Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.159Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.186Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:57.186Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.186Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.187Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.207Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.209Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.214Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Order"}}} +{"ts":"2025-08-10T03:23:57.214Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.214Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.215Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.242Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:57.242Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.242Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.244Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.271Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" done"}}} +{"ts":"2025-08-10T03:23:57.271Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.271Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.272Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.299Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" for"}}} +{"ts":"2025-08-10T03:23:57.299Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.299Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.301Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.312Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.314Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.328Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" centuries"}}} +{"ts":"2025-08-10T03:23:57.328Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.328Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.330Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.357Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:57.357Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.357Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.358Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.385Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" She"}}} +{"ts":"2025-08-10T03:23:57.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.385Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.387Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.414Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.414Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pledged"}}} +{"ts":"2025-08-10T03:23:57.414Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.414Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.415Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.443Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:57.443Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.443Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.444Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.471Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" keep"}}} +{"ts":"2025-08-10T03:23:57.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.471Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.472Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.500Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" it"}}} +{"ts":"2025-08-10T03:23:57.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.501Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.519Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.520Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.528Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" safe"}}} +{"ts":"2025-08-10T03:23:57.528Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.528Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.529Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.556Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" again"}}} +{"ts":"2025-08-10T03:23:57.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.556Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.557Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.584Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:57.585Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.585Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.586Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.613Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" so"}}} +{"ts":"2025-08-10T03:23:57.613Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.613Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.614Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.624Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.625Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.641Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:57.641Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.641Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.642Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.669Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" valley"}}} +{"ts":"2025-08-10T03:23:57.669Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.670Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.671Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.698Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" might"}}} +{"ts":"2025-08-10T03:23:57.698Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.698Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.699Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.726Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" thrive"}}} +{"ts":"2025-08-10T03:23:57.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.727Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.729Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.730Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.754Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:57.754Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.754Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.756Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.783Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:57.783Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.783Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.784Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.811Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:57.811Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.811Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.812Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.834Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.835Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.839Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" history"}}} +{"ts":"2025-08-10T03:23:57.839Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.839Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.840Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.877Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:57.877Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.877Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.878Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.906Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" compassion"}}} +{"ts":"2025-08-10T03:23:57.906Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.906Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.907Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.934Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.935Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:57.935Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.935Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.936Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.963Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" had"}}} +{"ts":"2025-08-10T03:23:57.963Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.963Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.964Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:57.991Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" guided"}}} +{"ts":"2025-08-10T03:23:57.991Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.991Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:57.992Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.019Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" generations"}}} +{"ts":"2025-08-10T03:23:58.019Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.019Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.021Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.039Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.041Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.048Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" might"}}} +{"ts":"2025-08-10T03:23:58.048Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.048Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.049Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.076Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" continue"}}} +{"ts":"2025-08-10T03:23:58.076Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.076Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.077Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.105Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":".\n\n"}}} +{"ts":"2025-08-10T03:23:58.105Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.106Z","dir":"to_tui","kind":"app_event","variant":"StartCommitAnimation"} +{"ts":"2025-08-10T03:23:58.106Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.108Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.134Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"So"}}} +{"ts":"2025-08-10T03:23:58.134Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.134Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.135Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.145Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.146Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.161Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:58.161Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:58.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.162Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:58.162Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.162Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.163Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.191Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:58.214Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:58.219Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" returned"}}} +{"ts":"2025-08-10T03:23:58.248Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:58.250Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"insert_history","lines":1} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.251Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.252Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.269Z","dir":"to_tui","kind":"app_event","variant":"CommitTick"} +{"ts":"2025-08-10T03:23:58.276Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:58.305Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" keep"}}} +{"ts":"2025-08-10T03:23:58.334Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:58.352Z","dir":"to_tui","kind":"app_event","variant":"StopCommitAnimation"} +{"ts":"2025-08-10T03:23:58.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.352Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.353Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.355Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.356Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.362Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" It"}}} +{"ts":"2025-08-10T03:23:58.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.362Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.363Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.391Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" was"}}} +{"ts":"2025-08-10T03:23:58.391Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.391Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.392Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.419Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:58.419Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.419Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.421Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.449Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" small"}}} +{"ts":"2025-08-10T03:23:58.449Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.449Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.451Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.460Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.461Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.478Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:58.478Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.478Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.479Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.507Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" steady"}}} +{"ts":"2025-08-10T03:23:58.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.507Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.508Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.535Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flame"}}} +{"ts":"2025-08-10T03:23:58.535Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.535Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.537Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.564Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" amid"}}} +{"ts":"2025-08-10T03:23:58.564Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.564Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.565Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.565Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.593Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" darkness"}}} +{"ts":"2025-08-10T03:23:58.593Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.593Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.594Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.622Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:58.622Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.622Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.623Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.650Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:58.650Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.650Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.652Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.670Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.671Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.679Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:23:58.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.679Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.680Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.708Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:58.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.708Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.709Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.736Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" reminder"}}} +{"ts":"2025-08-10T03:23:58.736Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.736Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.738Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.765Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:58.765Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.765Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.766Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.775Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.776Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.793Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" even"}}} +{"ts":"2025-08-10T03:23:58.793Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.793Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.794Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.821Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" in"}}} +{"ts":"2025-08-10T03:23:58.821Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.821Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.822Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.849Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" times"}}} +{"ts":"2025-08-10T03:23:58.849Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.849Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.850Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.877Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.878Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:58.878Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.878Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.879Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.906Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" great"}}} +{"ts":"2025-08-10T03:23:58.906Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.906Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.907Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.935Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" loss"}}} +{"ts":"2025-08-10T03:23:58.935Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.935Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.937Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.969Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:58.969Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.969Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.970Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.982Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.984Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:58.998Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" there"}}} +{"ts":"2025-08-10T03:23:58.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.998Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:58.999Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.026Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" is"}}} +{"ts":"2025-08-10T03:23:59.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.026Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.028Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.054Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" always"}}} +{"ts":"2025-08-10T03:23:59.055Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.055Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.056Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.083Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:23:59.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.083Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.085Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.085Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.086Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.112Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" spark"}}} +{"ts":"2025-08-10T03:23:59.112Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.112Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.113Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.141Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:23:59.141Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.141Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.142Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.169Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" persists"}}} +{"ts":"2025-08-10T03:23:59.169Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.169Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.171Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.187Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.188Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.198Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:23:59.198Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.198Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.199Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.227Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:23:59.227Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.227Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.228Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.255Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" people"}}} +{"ts":"2025-08-10T03:23:59.255Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.255Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.257Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.284Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:59.284Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.284Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.286Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.292Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.293Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.312Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ever"}}} +{"ts":"2025-08-10T03:23:59.312Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.313Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.314Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.341Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"g"}}} +{"ts":"2025-08-10T03:23:59.341Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.341Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.342Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.369Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"len"}}} +{"ts":"2025-08-10T03:23:59.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.371Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.397Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.398Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.398Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" learned"}}} +{"ts":"2025-08-10T03:23:59.398Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.398Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.400Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.427Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:59.427Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.427Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.428Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.456Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" cherish"}}} +{"ts":"2025-08-10T03:23:59.456Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.456Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.457Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.485Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:59.485Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.485Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.486Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.502Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.503Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.514Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:23:59.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.514Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.515Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.542Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:59.542Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.542Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.544Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.571Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:59.571Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.571Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.572Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.600Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" pass"}}} +{"ts":"2025-08-10T03:23:59.600Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.600Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.601Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.602Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.604Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.629Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" down"}}} +{"ts":"2025-08-10T03:23:59.629Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.629Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.630Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.657Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:23:59.657Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.657Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.659Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.686Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" stories"}}} +{"ts":"2025-08-10T03:23:59.686Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.686Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.687Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.707Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.709Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.715Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:23:59.715Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.715Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.716Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.744Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ca"}}} +{"ts":"2025-08-10T03:23:59.744Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.744Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.745Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.772Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"elan"}}} +{"ts":"2025-08-10T03:23:59.772Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.772Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.774Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.801Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"'s"}}} +{"ts":"2025-08-10T03:23:59.801Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.801Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.802Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.813Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.814Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.830Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" sacrifice"}}} +{"ts":"2025-08-10T03:23:59.830Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.830Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.831Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.859Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:23:59.859Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.859Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.860Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.887Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:23:59.887Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.887Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.889Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.916Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:23:59.916Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.916Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.918Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.918Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.945Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" respect"}}} +{"ts":"2025-08-10T03:23:59.945Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.945Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.946Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:23:59.974Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" its"}}} +{"ts":"2025-08-10T03:23:59.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.974Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:23:59.975Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.002Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" significance"}}} +{"ts":"2025-08-10T03:24:00.003Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.003Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.004Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.019Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.020Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.031Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:24:00.031Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.031Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.032Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.064Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" The"}}} +{"ts":"2025-08-10T03:24:00.064Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.064Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.066Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.094Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" final"}}} +{"ts":"2025-08-10T03:24:00.094Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.094Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.095Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.122Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.123Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:24:00.123Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.123Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.124Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.151Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" did"}}} +{"ts":"2025-08-10T03:24:00.151Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.151Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.153Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.180Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" not"}}} +{"ts":"2025-08-10T03:24:00.180Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.180Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.181Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.209Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" extingu"}}} +{"ts":"2025-08-10T03:24:00.209Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.209Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.210Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.227Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.229Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.238Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"ish"}}} +{"ts":"2025-08-10T03:24:00.238Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.238Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.239Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.267Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:24:00.267Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.267Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.268Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.295Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" entire"}}} +{"ts":"2025-08-10T03:24:00.295Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.295Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.297Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.324Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" valley"}}} +{"ts":"2025-08-10T03:24:00.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.324Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.325Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.332Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.334Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.353Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:24:00.353Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.353Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.354Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.382Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" it"}}} +{"ts":"2025-08-10T03:24:00.382Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.382Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.383Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.410Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" taught"}}} +{"ts":"2025-08-10T03:24:00.410Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.410Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.412Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.437Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.438Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.439Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" them"}}} +{"ts":"2025-08-10T03:24:00.439Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.439Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.440Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.468Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" something"}}} +{"ts":"2025-08-10T03:24:00.468Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.468Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.469Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.496Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" more"}}} +{"ts":"2025-08-10T03:24:00.496Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.496Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.498Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.525Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" profound"}}} +{"ts":"2025-08-10T03:24:00.525Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.525Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.526Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.542Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.543Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.554Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":":"}}} +{"ts":"2025-08-10T03:24:00.554Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.554Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.555Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.583Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:24:00.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.584Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.612Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:24:00.612Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.612Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.613Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.640Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" light"}}} +{"ts":"2025-08-10T03:24:00.640Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.640Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.641Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.647Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.648Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.669Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" is"}}} +{"ts":"2025-08-10T03:24:00.669Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.669Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.670Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.697Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" both"}}} +{"ts":"2025-08-10T03:24:00.698Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.698Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.699Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.726Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:24:00.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.726Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.728Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.752Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.753Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.755Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" blessing"}}} +{"ts":"2025-08-10T03:24:00.755Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.755Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.756Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.783Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:24:00.784Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.784Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.785Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.812Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:24:00.812Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.812Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.814Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.841Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" burden"}}} +{"ts":"2025-08-10T03:24:00.841Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.841Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.842Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.853Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.854Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.870Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:24:00.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.870Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.871Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.898Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" In"}}} +{"ts":"2025-08-10T03:24:00.898Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.898Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.900Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.927Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" Ever"}}} +{"ts":"2025-08-10T03:24:00.927Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.927Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.929Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.953Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.955Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.956Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"g"}}} +{"ts":"2025-08-10T03:24:00.956Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.956Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.957Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:00.984Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"len"}}} +{"ts":"2025-08-10T03:24:00.984Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.984Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:00.986Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.012Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:24:01.012Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.012Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.014Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.041Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:24:01.041Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.041Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.042Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.058Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.060Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.069Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" fire"}}} +{"ts":"2025-08-10T03:24:01.069Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.069Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.071Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.097Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" was"}}} +{"ts":"2025-08-10T03:24:01.097Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.097Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.099Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.126Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" never"}}} +{"ts":"2025-08-10T03:24:01.126Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.126Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.127Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.154Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" just"}}} +{"ts":"2025-08-10T03:24:01.154Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.154Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.156Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.161Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.162Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.188Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:24:01.188Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.188Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.189Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.217Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" physical"}}} +{"ts":"2025-08-10T03:24:01.217Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.217Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.218Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.246Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" flame"}}} +{"ts":"2025-08-10T03:24:01.246Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.246Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.247Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.264Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.266Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.274Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":";"}}} +{"ts":"2025-08-10T03:24:01.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.274Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.275Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.302Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" it"}}} +{"ts":"2025-08-10T03:24:01.302Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.302Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.304Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.331Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" became"}}} +{"ts":"2025-08-10T03:24:01.331Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.331Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.332Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.359Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" a"}}} +{"ts":"2025-08-10T03:24:01.361Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.361Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.363Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.369Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.370Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.388Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" guiding"}}} +{"ts":"2025-08-10T03:24:01.388Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.388Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.389Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.416Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" star"}}} +{"ts":"2025-08-10T03:24:01.416Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.416Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.418Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.446Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"—a"}}} +{"ts":"2025-08-10T03:24:01.446Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.446Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.447Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.469Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.471Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.475Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" symbol"}}} +{"ts":"2025-08-10T03:24:01.475Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.475Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.477Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.505Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" of"}}} +{"ts":"2025-08-10T03:24:01.505Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.505Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.506Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.533Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" humanity"}}} +{"ts":"2025-08-10T03:24:01.533Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.533Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.534Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.562Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"’s"}}} +{"ts":"2025-08-10T03:24:01.562Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.562Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.563Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.570Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.571Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.590Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ability"}}} +{"ts":"2025-08-10T03:24:01.590Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.590Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.591Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.618Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" to"}}} +{"ts":"2025-08-10T03:24:01.618Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.618Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.619Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.646Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" make"}}} +{"ts":"2025-08-10T03:24:01.646Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.646Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.647Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.671Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.673Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.674Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" difficult"}}} +{"ts":"2025-08-10T03:24:01.674Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.674Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.676Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.702Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" choices"}}} +{"ts":"2025-08-10T03:24:01.702Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.702Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.704Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.731Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":","}}} +{"ts":"2025-08-10T03:24:01.731Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.731Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.733Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.759Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" and"}}} +{"ts":"2025-08-10T03:24:01.759Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.759Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.760Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.773Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.774Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.787Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" the"}}} +{"ts":"2025-08-10T03:24:01.787Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.787Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.789Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.815Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" unwavering"}}} +{"ts":"2025-08-10T03:24:01.815Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.815Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.817Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.844Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" hope"}}} +{"ts":"2025-08-10T03:24:01.844Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.844Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.845Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.872Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" that"}}} +{"ts":"2025-08-10T03:24:01.872Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.872Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.873Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.878Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.879Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.900Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" even"}}} +{"ts":"2025-08-10T03:24:01.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.900Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.901Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.929Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" one"}}} +{"ts":"2025-08-10T03:24:01.929Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.929Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.930Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.957Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" ember"}}} +{"ts":"2025-08-10T03:24:01.957Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.957Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.959Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.983Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.984Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:01.985Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" can"}}} +{"ts":"2025-08-10T03:24:01.985Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.985Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:01.987Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:02.014Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" endure"}}} +{"ts":"2025-08-10T03:24:02.014Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.014Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.015Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:02.042Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":" forever"}}} +{"ts":"2025-08-10T03:24:02.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.042Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.043Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:02.070Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message_delta","delta":"."}}} +{"ts":"2025-08-10T03:24:02.071Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.071Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.072Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:02.084Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.086Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:02.099Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"agent_message","message":"**The Last Ember of Evernight**\n\nIn the valley of Everglen, where the mist clung to the emerald slopes like a silken shawl, the night never truly surrendered. Though the moon rose each evening, its pallid glow was always pierced by sparks that danced from the ancient Emberstone, a relic said to house a living fire. The stone rested in a cavern beneath the old Keep of Liora, guarded by the Order of Light, who swore to keep it untarnished from any mortal touch.\n\nAmong the village was a boy named Caelan. He had grown up hearing the legends – the tale of the Emberstone that kept the valley alive, of those who dared to steal its heart, and of the cursed ones who turned from the light. Caelan's mother, a healer known as Maeve, warned him never to venture beyond the river. Yet his eyes were always drawn toward the glimmering blue that peeked from the depths of the keep.\n\nOne crisp autumn night, a strange light fluttered across the sky, a comet streak that hung longer than it should. Caelan slipped away, his cloak a blur against the silvered riverbanks. He ran into the forest, guided by the comet’s tail, until he found himself at the base of the Keep of Liora. The stone walls gleamed, and an ancient oak's roots twined like silver cords across the stone.\n\nInside the keep, the corridors were maze-like, the air heavy with the scent of stone and lingering incense. At the heart of the labyrinth lay the Chamber of Ember, a vast vault carved out of obsidian, its walls pulsing faintly. The Emberstone lay atop a dais, surrounded by glyphs of forgotten runes that flickered like nervous fireflies.\n\nCaelan's heart hammered as he approached. He remembered Maeve's words: \"The fire of Evernight is not a tool to be wielded, but a covenant.\" Yet the ache of longing burned stronger—he wanted the light to pierce his village's nights, to cure the ailing crops, to ease his mother's grief. In a moment that felt like a choice between life and death he placed his palm upon the cool stone.\n\nSuddenly, a voice resonated through the chamber, not from any mortal ear, but from the very fire that had kept the valley alive: \"Child of ember, you seek light that cannot be taken. Do you dare become the flame?\"\n\nFrom behind him stepped a figure—tall and cloaked in a mantle of darkness that seemed to devour surrounding light. The figure revealed himself as Azrion the Keeper, the guardian who had sworn his oath to the stone for centuries. His eyes contained centuries of sorrow. \"I see your heart is pure,\" he intoned. \"But we are bound to the cycle.\"\n\nCaelan listened, heart pounding. \"I will spare the village,\" he said, voice trembling. \"But my mother suffers from illness. Will you grant me a sliver of fire?\"\n\nAzrion sighed. The Emberstone, however, had a memory. It remembered all that had come to its fore. The ancient runes whispered a verdict: the Emberstone can grant a single boon to a pure heart for a single sacrifice and that after the act, the light would be forever extinguished.\n\nCaelan realized the magnitude of his decision. If he used the fire, the valley would be plunged into darkness forever, but perhaps this one act would save a life. He closed his eyes, taking a deep breath, and placed the stone under a palm of his own. The ember sparked, a golden breath that curled into his fingers. Instinctively, he carried a vial of fire.\n\nHe returned to the village as the night fell. The flames were small and trembling, yet they lit the wounds of his mother's fever. Her eyes widened as warmth flooded through her bones. She smiled, a small but sincere expression of gratitude. \"My love,\" she whispered, \"you have saved me. I cannot say I understand how you'd choose our safety for a single flame...\"\n\nAfter that night, Caelan sealed the Emberstone back in its sanctuary and took the responsibility of keeping the light to the keep. Though the valley was robbed of its eternal ember, the memory lasted. His mother, healed, lived many more years. The village adapted, learning to find joy in the flicker of a single candle each night.\n\nYears turned into decades, and then centuries, until the Great Storm swept across Everglen, breaking the veil of the once unbreakable stone walls. The storm tore the Keep of Liora into ruins, leaving the Emberstone unprotected. In the ruins, whispers spread that someone from the past had stolen the ember, or that it had been left behind.\n\nIt was then that a new chapter began. A young woman named Aria, descendant of Caelan's line, with a keen curiosity and an unflinching resolve, would find her way. She would hear of the old stories of the stone's ember, the memory that carried the weight of choice.\n\nAria walked along the river and found herself at the ruins. She felt a pull in her heart and felt a stirring of destiny. She discovered, in the middle of the ruins, a cavity that glowed faintly in the moonlight: the Emberstone, broken, yet still lit, the same ember that had lived on Caelan's finger.\n\nWith careful hands, she reached for it. It was warm, and when she embraced it, she felt a vision: the valley under a storm, the light of the ember, Caelan's smile, the hope he brought to his mother. She understood then the weight of decisions, the price paid.\n\nAria understood that she must protect the ember, as the Order had done for centuries. She pledged to keep it safe again, so the valley might thrive, and the history of compassion that had guided generations might continue.\n\nSo the ember returned to its keep. It was a small, steady flame amid darkness, its light a reminder that even in times of great loss, there is always a spark that persists. The people of Everglen learned to cherish the ember, to pass down the stories of Caelan's sacrifice, and to respect its significance. The final ember did not extinguish the entire valley, it taught them something more profound: that the light is both a blessing and a burden. In Everglen, the fire was never just a physical flame; it became a guiding star—a symbol of humanity’s ability to make difficult choices, and the unwavering hope that even one ember can endure forever."}}} +{"ts":"2025-08-10T03:24:02.099Z","dir":"to_tui","kind":"codex_event","payload":{"id":"5","msg":{"type":"task_complete","last_agent_message":"**The Last Ember of Evernight**\n\nIn the valley of Everglen, where the mist clung to the emerald slopes like a silken shawl, the night never truly surrendered. Though the moon rose each evening, its pallid glow was always pierced by sparks that danced from the ancient Emberstone, a relic said to house a living fire. The stone rested in a cavern beneath the old Keep of Liora, guarded by the Order of Light, who swore to keep it untarnished from any mortal touch.\n\nAmong the village was a boy named Caelan. He had grown up hearing the legends – the tale of the Emberstone that kept the valley alive, of those who dared to steal its heart, and of the cursed ones who turned from the light. Caelan's mother, a healer known as Maeve, warned him never to venture beyond the river. Yet his eyes were always drawn toward the glimmering blue that peeked from the depths of the keep.\n\nOne crisp autumn night, a strange light fluttered across the sky, a comet streak that hung longer than it should. Caelan slipped away, his cloak a blur against the silvered riverbanks. He ran into the forest, guided by the comet’s tail, until he found himself at the base of the Keep of Liora. The stone walls gleamed, and an ancient oak's roots twined like silver cords across the stone.\n\nInside the keep, the corridors were maze-like, the air heavy with the scent of stone and lingering incense. At the heart of the labyrinth lay the Chamber of Ember, a vast vault carved out of obsidian, its walls pulsing faintly. The Emberstone lay atop a dais, surrounded by glyphs of forgotten runes that flickered like nervous fireflies.\n\nCaelan's heart hammered as he approached. He remembered Maeve's words: \"The fire of Evernight is not a tool to be wielded, but a covenant.\" Yet the ache of longing burned stronger—he wanted the light to pierce his village's nights, to cure the ailing crops, to ease his mother's grief. In a moment that felt like a choice between life and death he placed his palm upon the cool stone.\n\nSuddenly, a voice resonated through the chamber, not from any mortal ear, but from the very fire that had kept the valley alive: \"Child of ember, you seek light that cannot be taken. Do you dare become the flame?\"\n\nFrom behind him stepped a figure—tall and cloaked in a mantle of darkness that seemed to devour surrounding light. The figure revealed himself as Azrion the Keeper, the guardian who had sworn his oath to the stone for centuries. His eyes contained centuries of sorrow. \"I see your heart is pure,\" he intoned. \"But we are bound to the cycle.\"\n\nCaelan listened, heart pounding. \"I will spare the village,\" he said, voice trembling. \"But my mother suffers from illness. Will you grant me a sliver of fire?\"\n\nAzrion sighed. The Emberstone, however, had a memory. It remembered all that had come to its fore. The ancient runes whispered a verdict: the Emberstone can grant a single boon to a pure heart for a single sacrifice and that after the act, the light would be forever extinguished.\n\nCaelan realized the magnitude of his decision. If he used the fire, the valley would be plunged into darkness forever, but perhaps this one act would save a life. He closed his eyes, taking a deep breath, and placed the stone under a palm of his own. The ember sparked, a golden breath that curled into his fingers. Instinctively, he carried a vial of fire.\n\nHe returned to the village as the night fell. The flames were small and trembling, yet they lit the wounds of his mother's fever. Her eyes widened as warmth flooded through her bones. She smiled, a small but sincere expression of gratitude. \"My love,\" she whispered, \"you have saved me. I cannot say I understand how you'd choose our safety for a single flame...\"\n\nAfter that night, Caelan sealed the Emberstone back in its sanctuary and took the responsibility of keeping the light to the keep. Though the valley was robbed of its eternal ember, the memory lasted. His mother, healed, lived many more years. The village adapted, learning to find joy in the flicker of a single candle each night.\n\nYears turned into decades, and then centuries, until the Great Storm swept across Everglen, breaking the veil of the once unbreakable stone walls. The storm tore the Keep of Liora into ruins, leaving the Emberstone unprotected. In the ruins, whispers spread that someone from the past had stolen the ember, or that it had been left behind.\n\nIt was then that a new chapter began. A young woman named Aria, descendant of Caelan's line, with a keen curiosity and an unflinching resolve, would find her way. She would hear of the old stories of the stone's ember, the memory that carried the weight of choice.\n\nAria walked along the river and found herself at the ruins. She felt a pull in her heart and felt a stirring of destiny. She discovered, in the middle of the ruins, a cavity that glowed faintly in the moonlight: the Emberstone, broken, yet still lit, the same ember that had lived on Caelan's finger.\n\nWith careful hands, she reached for it. It was warm, and when she embraced it, she felt a vision: the valley under a storm, the light of the ember, Caelan's smile, the hope he brought to his mother. She understood then the weight of decisions, the price paid.\n\nAria understood that she must protect the ember, as the Order had done for centuries. She pledged to keep it safe again, so the valley might thrive, and the history of compassion that had guided generations might continue.\n\nSo the ember returned to its keep. It was a small, steady flame amid darkness, its light a reminder that even in times of great loss, there is always a spark that persists. The people of Everglen learned to cherish the ember, to pass down the stories of Caelan's sacrifice, and to respect its significance. The final ember did not extinguish the entire valley, it taught them something more profound: that the light is both a blessing and a burden. In Everglen, the fire was never just a physical flame; it became a guiding star—a symbol of humanity’s ability to make difficult choices, and the unwavering hope that even one ember can endure forever."}}} +{"ts":"2025-08-10T03:24:02.100Z","dir":"to_tui","kind":"insert_history","lines":3} +{"ts":"2025-08-10T03:24:02.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.101Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.102Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:24:02.187Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:24:02.191Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:48:49.756Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('c'), modifiers: KeyModifiers(CONTROL), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:48:49.758Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} +{"ts":"2025-08-10T03:48:49.759Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} +{"ts":"2025-08-10T03:48:49.822Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('c'), modifiers: KeyModifiers(CONTROL), kind: Release, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:48:49.925Z","dir":"to_tui","kind":"key_event","event":"KeyEvent { code: Char('c'), modifiers: KeyModifiers(CONTROL), kind: Press, state: KeyEventState(0x0) }"} +{"ts":"2025-08-10T03:48:49.925Z","dir":"from_tui","kind":"op","payload":{"type":"shutdown"}} +{"ts":"2025-08-10T03:48:49.926Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Shutting down Codex instance"} +{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Aborting existing session"} +{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"codex_event","payload":{"id":"7","msg":{"type":"shutdown_complete"}}} +{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"Exit"} +{"ts":"2025-08-10T03:48:49.927Z","dir":"meta","kind":"session_end"} diff --git a/codex-rs/tui/tests/suite/directory_trust.rs b/codex-rs/tui/tests/suite/directory_trust.rs new file mode 100644 index 0000000000000000000000000000000000000000..9c4326a4af12ce379486a554ae4a2c9fe7c5cd27 --- /dev/null +++ b/codex-rs/tui/tests/suite/directory_trust.rs @@ -0,0 +1,232 @@ +//! Exercises folder consent against a connected server through the real terminal event loop. + +use super::focus_palette::PtyCodex; +use super::focus_palette::write_test_config; +use anyhow::Result; +use codex_app_server_protocol::JSONRPCMessage; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::time::Duration; +use tokio::net::UnixListener; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn connected_trust_cancellation_and_acceptance_control_task_creation() -> Result<()> { + for trust_level in [None, Some("untrusted")] { + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex_home = tempfile::tempdir_in("/tmp")?; + // The server's trust decision must win over the client's trusted-folder setting. + write_test_config(codex_home.path(), &repo_root)?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!("{config}\n[tui]\nresume_cwd = \"current\"\n"), + )?; + let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?; + std::fs::create_dir_all(socket.parent().unwrap())?; + let listener = UnixListener::bind(socket.as_path())?; + let cwd = repo_root.clone(); + let methods = Arc::new(Mutex::new(Vec::new())); + let requests = Arc::clone(&methods); + let server = tokio::spawn(async move { + let mut clients = tokio::task::JoinSet::new(); + let trust_reads = Arc::new(AtomicUsize::new(0)); + loop { + let (stream, _) = listener + .accept() + .await + .expect("accept fake app-server client"); + let cwd = cwd.clone(); + let methods = Arc::clone(&requests); + let trust_reads = Arc::clone(&trust_reads); + clients.spawn(async move { + let Ok(mut socket) = tokio_tungstenite::accept_async(stream).await else { + return Ok::<_, anyhow::Error>(()); + }; + let other_cwd = cwd.join("other-folder"); + let id = "00000000-0000-0000-0000-000000000001"; + let thread = json!({ + "id": id, "sessionId": id, "preview": "Untrusted saved task", "ephemeral": false, + "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, + "status": if trust_level.is_some() { json!({"type": "active", "activeFlags": []}) } else { json!({"type": "notLoaded"}) }, "cwd": other_cwd, + "cliVersion": "0.0.0", "source": "cli", "turns": [] + }); + let mut launch_thread = thread.clone(); + launch_thread["id"] = json!("00000000-0000-0000-0000-000000000002"); + launch_thread["sessionId"] = launch_thread["id"].clone(); + launch_thread["cwd"] = json!(cwd); + launch_thread["preview"] = json!("Launch-folder task"); + launch_thread["updatedAt"] = json!(1); + while let Some(Ok(Message::Text(text))) = socket.next().await { + let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { + continue; + }; + methods.lock().unwrap().push(request.method.clone()); + let result = match request.method.as_str() { + "initialize" => json!({"userAgent": "trust-pty"}), + "account/read" => { + json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false}) + } + "config/read" => { + if request + .params + .as_ref() + .is_some_and(|params| params["includeLayers"] == true) + { + trust_reads.fetch_add(1, Ordering::SeqCst); + } + json!({"config": {"model": "gpt-5.6-terra", "projects": { + cwd.to_string_lossy(): {"trust_level": if trust_reads.load(Ordering::SeqCst) == 4 { Some("trusted") } else { trust_level }}, + other_cwd.to_string_lossy(): {"trust_level": "untrusted"}, + cwd.join("moved-folder").to_string_lossy(): {"trust_level": "untrusted"} + }}, "origins": {}, "layers": []}) + } + "config/batchWrite" => json!({"status": "ok", "version": "1", "filePath": cwd.join("config.toml"), "overriddenMetadata": null}), + "hooks/list" => json!({"data": if trust_level.is_none() && methods.lock().unwrap().iter().any(|method| method == "config/batchWrite") { + vec![json!({"cwd": request.params.as_ref().unwrap()["cwds"][0], "hooks": [{ + "key": "project:test", "eventName": "sessionStart", "handlerType": "command", + "command": "echo hook", "async": false, "matcher": null, "timeoutSec": 30, + "statusMessage": null, "additionalContextLimit": null, + "sourcePath": cwd.join(".codex/hooks.json"), "source": "project", "pluginId": null, + "displayOrder": 0, "enabled": false, "isManaged": false, + "currentHash": "sha256:test", "trustStatus": "untrusted" + }], "warnings": [], "errors": []})] + } else { vec![] }}), + "configRequirements/read" => json!({"requirements": null}), + "model/list" => json!({"data": [], "nextCursor": null}), + "thread/list" => { + json!({"data": vec![thread.clone(), launch_thread.clone()], "nextCursor": null}) + } + "thread/read" => { + let mut thread = if request.params.as_ref().unwrap()["threadId"] == launch_thread["id"] { + launch_thread.clone() + } else { + thread.clone() + }; + if thread["id"] == id && trust_reads.load(Ordering::SeqCst) >= 5 { + thread["cwd"] = json!(cwd.join("moved-folder")); + } + json!({"thread": thread}) + } , + "skills/list" | "thread/loaded/list" => json!({"data": []}), + _ => { + socket + .send(Message::Text( + json!({"id": request.id, "error": { + "code": if request.method == "thread/start" { -32000 } else { -32601 }, "message": if request.method == "thread/start" { "Consent accepted" } else { "method not found" } + }}) + .to_string() + .into(), + )) + .await?; + continue; + } + }; + socket + .send(Message::Text( + json!({"id": request.id, "result": result}) + .to_string() + .into(), + )) + .await?; + } + Ok(()) + }); + } + }); + let mut terminal = PtyCodex::start( + &repo_root, + codex_home, + &["do not submit this launch prompt"], + )?; + let prompt = if trust_level.is_some() { + "Open restricted" + } else { + "Trust and continue" + }; + for (expected, input) in [ + (prompt, b"\x1b".as_slice()), + ("n new", b"\x1b"), + ("Launch-folder task", b"\x1b[Bn"), + (prompt, b"\x1b"), + ("n new", b"n"), + ("You are in", b"\x1b"), + ("o resume", b"o"), + ("Resume a previous session", b"\x1b[C"), + ("Untrusted saved task", b"\r"), + ("Open existing task", b"\r"), + ("moved-folder", b"\x1b"), + ("o resume", b"n"), + (prompt, b"\r"), + ] { + let is_consent = expected == prompt + || matches!( + expected, + "Open existing task" | "moved-folder" | "You are in" + ); + terminal.wait_for_screen(expected)?; + if expected == "You are in" { + assert_eq!( + terminal + .screen_contents() + .lines() + .find(|line| line.contains("You are in")), + Some(format!("> You are in {}", repo_root.display()).as_str()) + ); + } + if is_consent { + terminal.wait_for_screen("Back to Agent Command Center")?; + } + // Confirmations must be fresh input after the protected-screen transition. + tokio::time::sleep(Duration::from_millis(/*millis*/ 200)).await; + terminal.write_input(input)?; + terminal.read_output(Duration::from_millis(/*millis*/ 100))?; + } + if trust_level.is_none() { + terminal.wait_for_screen("Hooks need review")?; + assert!( + !methods + .lock() + .unwrap() + .iter() + .any(|method| method == "thread/start") + ); + tokio::time::sleep(Duration::from_millis(/*millis*/ 200)).await; + terminal.write_input(b"3")?; + } + terminal.wait_for_screen("Consent accepted")?; + drop(terminal); + server.abort(); + let methods = methods.lock().unwrap(); + let mutations: Vec<_> = methods + .iter() + .map(String::as_str) + .filter(|method| { + matches!( + *method, + "thread/start" + | "thread/resume" + | "thread/fork" + | "turn/start" + | "config/batchWrite" + ) + }) + .collect(); + assert_eq!( + mutations, + if trust_level.is_some() { + vec!["thread/start"] + } else { + vec!["config/batchWrite", "thread/start"] + } + ); + } + Ok(()) +} diff --git a/codex-rs/tui/tests/suite/focus_palette.rs b/codex-rs/tui/tests/suite/focus_palette.rs new file mode 100644 index 0000000000000000000000000000000000000000..a29e3b50998f427d1afa3fe1dcf9b3523ab60184 --- /dev/null +++ b/codex-rs/tui/tests/suite/focus_palette.rs @@ -0,0 +1,398 @@ +use std::fs::File; +use std::io::Read; +use std::io::Write; +use std::os::fd::AsRawFd; +use std::os::fd::FromRawFd; +use std::os::fd::OwnedFd; +use std::path::Path; +use std::process::Child; +use std::process::Command; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use anyhow::ensure; +#[cfg(target_os = "macos")] +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +// Full startup continues after the composer first appears and can be slower under Rosetta in CI. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); +const FOCUS_INPUT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5); +const FOCUS_PROBE_INPUT: &str = "focus-palette-24527"; + +#[test] +fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> Result<()> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex_home = tempfile::tempdir()?; + write_test_config(codex_home.path(), &repo_root)?; + + let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?; + terminal.wait_for_startup()?; + + let startup_output_len = terminal.output.len(); + let focus_started = Instant::now(); + terminal.write_input(format!("\u{1b}[I{FOCUS_PROBE_INPUT}").as_bytes())?; + terminal.wait_for_focus_input(FOCUS_PROBE_INPUT, focus_started, startup_output_len)?; + + let delayed_input = format!("{FOCUS_PROBE_INPUT}-delayed"); + let delayed_focus_started = Instant::now(); + terminal.write_input(b"\x1b[I")?; + terminal.read_output(Duration::from_millis(/*millis*/ 20))?; + terminal.write_input(delayed_input.as_bytes())?; + terminal.wait_for_focus_input(&delayed_input, delayed_focus_started, startup_output_len)?; + + Ok(()) +} + +#[cfg(target_os = "macos")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interactive_startup_honors_codex_home_symlink_opt_out() -> Result<()> { + use core_test_support::responses; + use wiremock::matchers::body_string_contains; + + core_test_support::skip_if_sandbox!(Ok(())); + let workspace = tempfile::tempdir()?; + let workspace_path = workspace.path().canonicalize()?; + let codex_home = tempfile::tempdir()?; + let target = tempfile::tempdir()?; + let visualizations = codex_home.path().join("visualizations"); + std::os::unix::fs::symlink(target.path(), &visualizations)?; + write_test_config(codex_home.path(), &workspace_path)?; + + let server = responses::start_mock_server().await; + // Automatic thread-title requests must not consume the tool responses. + let _title_mock = responses::mount_sse_once_match( + &server, + body_string_contains(r#"\"thread_source\":\"system\""#), + responses::sse(vec![ + responses::ev_assistant_message("title", r#"{"title":"Symlink probe"}"#), + responses::ev_completed("title"), + ]), + ) + .await; + let _write_mock = responses::mount_sse_once_match( + &server, + body_string_contains(r#"\"thread_source\":\"user\""#), + responses::sse(vec![ + responses::ev_exec_command_call_with_args( + "write", + &serde_json::json!({ + "cmd": "printf ready > ready.txt", + "workdir": visualizations, + "shell": "/bin/sh", + "login": false, + }), + ), + responses::ev_completed("startup"), + ]), + ) + .await; + let completion_mock = responses::mount_sse_once_match( + &server, + body_string_contains(r#"\"thread_source\":\"user\""#), + responses::sse(vec![ + responses::ev_assistant_message("ready", "symlink-opt-out-ready"), + responses::ev_completed("complete"), + ]), + ) + .await; + let base_url = server.uri(); + let visualizations = toml::Value::String(visualizations.display().to_string()); + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!( + "allow_symlinked_codex_home = true\n\ + sandbox_mode = \"workspace-write\"\n\ + approval_policy = \"never\"\n\ + {config}\n\ + [sandbox_workspace_write]\nwritable_roots = [{visualizations}]\n\ + [model_providers.test]\nname = \"Mock\"\n\ + base_url = \"{base_url}/v1\"\nwire_api = \"responses\"\n\ + requires_openai_auth = false\nsupports_websockets = false\n" + ), + )?; + + // A fresh home has no daemon. Writing through the link exercises the embedded server's + // local executor and its sandbox policy. + let mut terminal = PtyCodex::start( + &workspace_path, + codex_home, + &["-c", "model_provider=\"test\"", "Write ready.txt"], + )?; + terminal.wait_for_startup()?; + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + terminal.read_output(Duration::from_millis(/*millis*/ 50))?; + if terminal.screen_contains("symlink-opt-out-ready") { + let tool_output = completion_mock + .function_call_output_text("write") + .context("missing symlink write tool output")?; + ensure!( + tool_output.contains("Process exited with code 0"), + "{tool_output}" + ); + assert_eq!(std::fs::read(target.path().join("ready.txt"))?, b"ready"); + return Ok(()); + } + if terminal.child.try_wait()?.is_some() { + break; + } + } + bail!( + "interactive startup did not honor the symlink opt-out; screen:\n{}", + terminal.screen_contents(), + ); +} + +pub(super) struct PtyCodex { + master: File, + child: Child, + parser: vt100::Parser, + output: Vec, + cursor_answered: bool, + palette_answered: bool, + keyboard_answered: bool, + _codex_home: TempDir, +} + +impl PtyCodex { + pub(super) fn start( + repo_root: &Path, + codex_home: TempDir, + extra_args: &[&str], + ) -> Result { + let mut master_fd = -1; + let mut slave_fd = -1; + let mut window_size = libc::winsize { + ws_row: 32, + ws_col: 120, + ws_xpixel: 0, + ws_ypixel: 0, + }; + + // SAFETY: `openpty` initializes both file descriptors on success, and the supplied window + // size remains valid for the duration of the call. + let result = unsafe { + libc::openpty( + &mut master_fd, + &mut slave_fd, + /*name*/ std::ptr::null_mut(), + /*termp*/ std::ptr::null_mut(), + &raw mut window_size, + ) + }; + if result == -1 { + return Err(std::io::Error::last_os_error()).context("open focus-test pseudo-terminal"); + } + + // SAFETY: a successful `openpty` transfers ownership of both unique file descriptors. + let master = File::from(unsafe { OwnedFd::from_raw_fd(master_fd) }); + // SAFETY: `slave_fd` is the second unique descriptor initialized by `openpty`. + let slave = File::from(unsafe { OwnedFd::from_raw_fd(slave_fd) }); + let stdin = slave.try_clone().context("clone pseudo-terminal stdin")?; + let stdout = slave.try_clone().context("clone pseudo-terminal stdout")?; + + let codex = codex_utils_cargo_bin::cargo_bin("codex-tui") + .or_else(|_| codex_utils_cargo_bin::cargo_bin("codex"))?; + let child = Command::new(codex) + .args(extra_args) + .arg("--no-alt-screen") + .arg("-C") + .arg(repo_root) + .env("TERM", "xterm-256color") + .env("OPENAI_API_KEY", "focus-palette-test") + .env("CODEX_HOME", codex_home.path()) + .stdin(stdin) + .stdout(stdout) + .stderr(slave) + .spawn() + .context("start Codex in focus-test pseudo-terminal")?; + + Ok(Self { + master, + child, + parser: vt100::Parser::new( + /*rows*/ 32, /*cols*/ 120, /*scrollback_len*/ 0, + ), + output: Vec::new(), + cursor_answered: false, + palette_answered: false, + keyboard_answered: false, + _codex_home: codex_home, + }) + } + + pub(super) fn wait_for_startup(&mut self) -> Result<()> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + self.read_output(Duration::from_millis(/*millis*/ 50))?; + self.answer_startup_queries()?; + + if self.palette_answered && self.screen_contains("OpenAI Codex") { + return Ok(()); + } + + if let Some(status) = self.child.try_wait()? { + bail!( + "Codex exited before the focus test started ({status}); screen:\n{}", + self.screen_contents(), + ); + } + } + + bail!( + "Codex did not initialize within {:?}; screen:\n{}", + STARTUP_TIMEOUT, + self.screen_contents(), + ); + } + + fn wait_for_focus_input( + &mut self, + input: &str, + focus_started: Instant, + startup_output_len: usize, + ) -> Result<()> { + while focus_started.elapsed() < FOCUS_INPUT_TIMEOUT { + self.read_output(Duration::from_millis(/*millis*/ 20))?; + let focus_output = &self.output[startup_output_len..]; + ensure!( + !contains_bytes(focus_output, b"\x1b]10;?") + && !contains_bytes(focus_output, b"\x1b]11;?"), + "focus regain queried terminal colors after the startup palette was cached", + ); + if self.screen_contains(input) { + return Ok(()); + } + } + + bail!( + "focus-time palette refresh blocked or discarded {input:?} for more than {:?}; \ + screen:\n{}", + FOCUS_INPUT_TIMEOUT, + self.screen_contents(), + ); + } + + fn answer_startup_queries(&mut self) -> Result<()> { + if !self.cursor_answered && contains_bytes(&self.output, b"\x1b[6n") { + self.write_input(b"\x1b[1;1R")?; + self.cursor_answered = true; + } + + if !self.keyboard_answered && contains_bytes(&self.output, b"\x1b[?u") { + self.write_input(b"\x1b[?0u\x1b[?1;2c")?; + self.keyboard_answered = true; + } + + if !self.palette_answered + && contains_bytes(&self.output, b"\x1b]10;?") + && contains_bytes(&self.output, b"\x1b]11;?") + { + self.write_input(b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\\x1b]11;rgb:0000/0000/0000\x1b\\")?; + self.palette_answered = true; + } + + Ok(()) + } + + pub(super) fn read_output(&mut self, timeout: Duration) -> Result<()> { + let timeout_ms = timeout.as_millis().min(libc::c_int::MAX as u128) as libc::c_int; + let mut descriptor = libc::pollfd { + fd: self.master.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + + // SAFETY: `descriptor` points to one initialized poll descriptor. + let ready = unsafe { + libc::poll(&mut descriptor, /*nfds*/ 1, timeout_ms) + }; + if ready == -1 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + return Ok(()); + } + return Err(error).context("poll focus-test pseudo-terminal"); + } + if ready == 0 || descriptor.revents & libc::POLLIN == 0 { + return Ok(()); + } + + let mut chunk = [0_u8; 8192]; + let count = self.master.read(&mut chunk)?; + self.output.extend_from_slice(&chunk[..count]); + self.parser.process(&chunk[..count]); + Ok(()) + } + + pub(super) fn write_input(&mut self, bytes: &[u8]) -> Result<()> { + self.master.write_all(bytes)?; + self.master.flush()?; + Ok(()) + } + + pub(super) fn screen_contains(&self, text: &str) -> bool { + self.parser.screen().contents().contains(text) + } + + pub(super) fn screen_contents(&self) -> String { + self.parser.screen().contents() + } + + pub(super) fn wait_for_screen(&mut self, text: &str) -> Result<()> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + self.read_output(Duration::from_millis(/*millis*/ 50))?; + self.answer_startup_queries()?; + if self.screen_contains(text) { + return Ok(()); + } + if let Some(status) = self.child.try_wait()? { + bail!("Codex exited while waiting for {text:?} ({status})"); + } + } + bail!("missing {text:?}; screen:\n{}", self.screen_contents()) + } + + pub(super) fn ensure_running(&mut self) -> Result<()> { + ensure!( + self.child.try_wait()?.is_none(), + "Codex exited unexpectedly; screen:\n{}", + self.screen_contents() + ); + Ok(()) + } +} + +impl Drop for PtyCodex { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn contains_bytes(buffer: &[u8], needle: &[u8]) -> bool { + buffer.windows(needle.len()).any(|window| window == needle) +} + +pub(super) fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<()> { + let repo_root = repo_root.display(); + let config = format!( + "model = \"gpt-5.6-terra\"\nmodel_provider = \"openai\"\n\ + suppress_unstable_features_warning = true\nanalytics.enabled = false\n\n\ + [projects.\"{repo_root}\"]\ntrust_level = \"trusted\"\n" + ); + std::fs::write(codex_home.join("config.toml"), config) + .context("write focus-test Codex configuration")?; + std::fs::write( + codex_home.join("auth.json"), + r#"{"OPENAI_API_KEY":"focus-palette-test","tokens":null,"last_refresh":null}"#, + ) + .context("write focus-test API-key authentication") +} diff --git a/codex-rs/tui/tests/suite/mod.rs b/codex-rs/tui/tests/suite/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..eb2b3e0ba66839c34d73fbdfcf2f9849f2a08206 --- /dev/null +++ b/codex-rs/tui/tests/suite/mod.rs @@ -0,0 +1,13 @@ +// Aggregates all former standalone integration tests as modules. +#[cfg(unix)] +mod directory_trust; +#[cfg(unix)] +mod focus_palette; +#[cfg(unix)] +mod reconnect; +mod resize_reflow; +mod status_indicator; +mod vt100_history; +mod vt100_live_commit; +#[cfg(unix)] +mod worktree_stack; diff --git a/codex-rs/tui/tests/suite/reconnect.rs b/codex-rs/tui/tests/suite/reconnect.rs new file mode 100644 index 0000000000000000000000000000000000000000..05f2eef87d079a9ea205240be2a90fe766276f87 --- /dev/null +++ b/codex-rs/tui/tests/suite/reconnect.rs @@ -0,0 +1,196 @@ +//! Drives automatic reconnect through the real binary and terminal event loop. + +use super::focus_palette::PtyCodex; +use super::focus_palette::write_test_config; +use anyhow::Result; +use anyhow::ensure; +use codex_app_server_protocol::JSONRPCMessage; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; +use std::time::Instant; +use tokio::net::UnixListener; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Result<()> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + // macOS's default temporary directory leaves too little room for the control socket path. + let codex_home = tempfile::tempdir_in("/tmp")?; + write_test_config(codex_home.path(), &repo_root)?; + let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?; + std::fs::create_dir_all(socket.parent().unwrap())?; + let listener = UnixListener::bind(socket.as_path())?; + let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel(); + let (restore_tx, restore_rx) = tokio::sync::oneshot::channel(); + let server_cwd = repo_root.clone(); + let server = tokio::spawn(async move { + let mut methods = Vec::new(); + let mut restore_rx = Some(restore_rx); + let id = "00000000-0000-0000-0000-000000000001"; + let thread = json!({ + "id": id, "sessionId": id, "preview": "", "ephemeral": false, + "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, + "status": {"type": "active", "activeFlags": []}, "cwd": server_cwd, + "cliVersion": "0.0.0", "source": "cli", "turns": [{"id": "running", "items": [], "status": "inProgress", "error": null}] + }); + for connection in 0..3 { + let mut socket = loop { + let (stream, _) = listener.accept().await?; + // Startup probes the default daemon socket before opening its WebSocket. + if let Ok(socket) = tokio_tungstenite::accept_async(stream).await { + break socket; + } + }; + loop { + let frame = tokio::select! { + _ = &mut disconnect_rx, if connection == 0 => { + socket.close(/*msg*/ None).await?; + break; + } + frame = socket.next() => frame, + }; + let Some(Ok(Message::Text(text))) = frame else { + break; + }; + let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { + continue; + }; + methods.push(request.method.clone()); + if connection == 1 && request.method == "initialize" { + restore_rx.take().unwrap().await?; + } + if connection == 1 && request.method == "thread/resume" { + socket + .send(Message::Text( + json!({"id": request.id, "error": { + "code": -32600, + "message": format!("thread {id} is closing; retry after the thread is closed") + }}) + .to_string() + .into(), + )) + .await?; + continue; + } + let result = match request.method.as_str() { + "initialize" => json!({"userAgent": "reconnect-pty"}), + "account/read" => { + json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false}) + } + "model/list" => json!({"data": [], "nextCursor": null}), + "config/read" => { + json!({"config": {"model": "gpt-5.6-terra", "model_provider": "openai", "projects": { + server_cwd.to_string_lossy(): {"trust_level": "trusted"} + }}, "origins": {}, "layers": []}) + } + "configRequirements/read" => json!({"requirements": null}), + "thread/start" | "thread/resume" => { + json!({"thread": thread, "model": "gpt-5.6-terra", "modelProvider": "openai", + "cwd": server_cwd, "approvalPolicy": "never", "approvalsReviewer": "user", + "sandbox": {"type": "readOnly"}, "reasoningEffort": null}) + } + "thread/read" => json!({"thread": thread}), + "thread/goal/get" => json!({"goal": null}), + "skills/list" => json!({"data": []}), + _ => { + socket + .send(Message::Text( + json!({"id": request.id, "error": { + "code": -32601, "message": "method not found" + }}) + .to_string() + .into(), + )) + .await?; + continue; + } + }; + socket + .send(Message::Text( + json!({"id": request.id, "result": result}) + .to_string() + .into(), + )) + .await?; + if connection == 2 && request.method == "thread/resume" { + // Keep the recovered turn running: its output must appear without waiting + // for turn/completed or rebuilding the transcript. + socket + .send(Message::Text( + json!({ + "method": "item/agentMessage/delta", "params": { + "threadId": id, "turnId": "running", "itemId": "live-item", + "delta": "fresh-notification-after-reconnect\n" + } + }) + .to_string() + .into(), + )) + .await?; + } + } + } + Ok::<_, anyhow::Error>(methods) + }); + let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?; + terminal.wait_for_startup()?; + let mut disconnect_tx = Some(disconnect_tx); + let mut restore_tx = Some(restore_tx); + for expected in [ + "gpt-5.6-terra", + "preserved-draft", + "Reconnecting", + "preserved-draft!", + "fresh-notification-after-reconnect", + ] { + let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30); + while !terminal.screen_contains(expected) && Instant::now() < deadline { + terminal.read_output(Duration::from_millis(/*millis*/ 20))?; + } + ensure!( + terminal.screen_contains(expected), + "missing {expected}; screen:\n{}", + terminal.screen_contents() + ); + match expected { + "gpt-5.6-terra" => terminal.write_input(b"preserved-draft")?, + "preserved-draft" => { + disconnect_tx.take().unwrap().send(()).unwrap(); + } + "Reconnecting" => terminal.write_input(b"!")?, + "preserved-draft!" => { + restore_tx.take().unwrap().send(()).unwrap(); + } + _ => {} + } + } + // A PTY read can expose the history write before the same terminal update redraws the + // composer. Wait for both pieces in the same parsed screen before checking recovery. + let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30); + while !(terminal.screen_contains("preserved-draft!") + && terminal.screen_contains("fresh-notification-after-reconnect")) + && Instant::now() < deadline + { + terminal.read_output(Duration::from_millis(/*millis*/ 20))?; + } + ensure!( + terminal.screen_contains("preserved-draft!") + && terminal.screen_contains("fresh-notification-after-reconnect"), + "draft and notification did not remain visible after recovery; screen:\n{}", + terminal.screen_contents() + ); + drop(terminal); + let methods = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), server).await???; + assert_eq!( + methods + .iter() + .filter(|method| *method == "thread/resume") + .count(), + 2 + ); + assert!(!methods.iter().any(|method| method == "turn/start")); + Ok(()) +} diff --git a/codex-rs/tui/tests/suite/resize_reflow.rs b/codex-rs/tui/tests/suite/resize_reflow.rs new file mode 100644 index 0000000000000000000000000000000000000000..89928de3af9863ea7e1185d27f708dab43ccfd41 --- /dev/null +++ b/codex-rs/tui/tests/suite/resize_reflow.rs @@ -0,0 +1,704 @@ +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Output; +use std::thread::sleep; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use tempfile::tempdir; +use wiremock::MockServer; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] +async fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Result<()> { + if cfg!(windows) { + return Ok(()); + } + skip_if_no_network!(Ok(())); + if Command::new("tmux").arg("-V").output().is_err() { + eprintln!("skipping resize smoke because tmux is unavailable"); + return Ok(()); + } + + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex = codex_binary(&repo_root)?; + let codex_home = tempdir()?; + let server = MockServer::start().await; + let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; + let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); + write_config(codex_home.path(), &repo_root)?; + write_auth(codex_home.path())?; + + let session_name = format!("codex-resize-reflow-smoke-{}", std::process::id()); + let _session = TmuxSession { + name: session_name.clone(), + }; + + let prompt = "Say hi."; + let start_output = checked_output( + Command::new("tmux") + .arg("new-session") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-x") + .arg("120") + .arg("-y") + .arg("40") + .arg("-s") + .arg(&session_name) + .arg("--") + .arg("env") + .arg(format!("CODEX_HOME={}", codex_home.path().display())) + .arg("OPENAI_API_KEY=dummy") + .arg(codex) + .arg("-c") + .arg("analytics.enabled=false") + .arg("-c") + .arg(&openai_base_url_config) + .arg("--no-alt-screen") + .arg("-C") + .arg(&repo_root) + .arg(prompt), + )?; + let codex_pane = stdout_text(&start_output).trim().to_string(); + anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); + + wait_for_capture_contains( + &codex_pane, + "resize reflow sentinel", + Duration::from_secs(/*secs*/ 15), + )?; + wait_for_capture_contains( + &codex_pane, + "gpt-5.4 default", + Duration::from_secs(/*secs*/ 15), + )?; + let draft = "Notice where we are here in terms of y location."; + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("-l") + .arg(draft), + )?; + let baseline_capture = + wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; + let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; + let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") + .context("history row before split")?; + + let split_output = checked_output( + Command::new("tmux") + .arg("split-window") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-v") + .arg("-l") + .arg("12") + .arg("-t") + .arg(&codex_pane) + .arg("sleep") + .arg("30"), + )?; + let split_pane = stdout_text(&split_output).trim().to_string(); + + sleep(Duration::from_millis(/*millis*/ 250)); + let first_capture = capture_pane(&codex_pane)?; + let first_row = last_composer_row(&first_capture).context("composer row after split")?; + + sleep(Duration::from_millis(/*millis*/ 1_000)); + let second_capture = capture_pane(&codex_pane)?; + let second_row = + last_composer_row(&second_capture).context("composer row after reflow wait")?; + + anyhow::ensure!( + first_row == second_row, + "composer row drifted after split: before={first_row}, after={second_row}\n\ + before:\n{first_capture}\n\ + after:\n{second_capture}" + ); + anyhow::ensure!( + second_row <= baseline_row + 1, + "composer row snapped downward after split: baseline={baseline_row}, after={second_row}\n\ + baseline:\n{baseline_capture}\n\ + after:\n{second_capture}" + ); + + check( + Command::new("tmux") + .arg("kill-pane") + .arg("-t") + .arg(&split_pane), + )?; + + sleep(Duration::from_millis(/*millis*/ 500)); + let final_capture = capture_pane(&codex_pane)?; + let final_row = + last_composer_row(&final_capture).context("composer row after closing split")?; + anyhow::ensure!( + final_row == baseline_row, + "composer row drifted after closing split: baseline={baseline_row}, after={final_row}\n\ + capture:\n{final_capture}" + ); + let final_history_row = first_row_containing(&final_capture, "resize reflow sentinel") + .context("history row after closing split")?; + anyhow::ensure!( + final_history_row == baseline_history_row, + "history row drifted after closing split: baseline={baseline_history_row}, \ + after={final_history_row}\n\ + baseline:\n{baseline_capture}\n\ + after:\n{final_capture}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] +async fn tmux_repeated_resizes_do_not_push_composer_down() -> Result<()> { + if cfg!(windows) { + return Ok(()); + } + skip_if_no_network!(Ok(())); + if Command::new("tmux").arg("-V").output().is_err() { + eprintln!("skipping resize smoke because tmux is unavailable"); + return Ok(()); + } + + run_repeated_resize_smoke().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] +async fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> { + if cfg!(windows) { + return Ok(()); + } + skip_if_no_network!(Ok(())); + if Command::new("tmux").arg("-V").output().is_err() { + eprintln!("skipping resize smoke because tmux is unavailable"); + return Ok(()); + } + + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex = codex_binary(&repo_root)?; + let codex_home = tempdir()?; + let server = MockServer::start().await; + let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; + let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); + write_config(codex_home.path(), &repo_root)?; + write_auth(codex_home.path())?; + + let session_name = format!("codex-resize-width-{}", std::process::id()); + let _session = TmuxSession { + name: session_name.clone(), + }; + + let prompt = "Send me a large paragraph of text for testing."; + let start_output = checked_output( + Command::new("tmux") + .arg("new-session") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-x") + .arg("120") + .arg("-y") + .arg("40") + .arg("-s") + .arg(&session_name) + .arg("--") + .arg("env") + .arg(format!("CODEX_HOME={}", codex_home.path().display())) + .arg("OPENAI_API_KEY=dummy") + .arg(codex) + .arg("-c") + .arg("analytics.enabled=false") + .arg("-c") + .arg(&openai_base_url_config) + .arg("--no-alt-screen") + .arg("-C") + .arg(&repo_root) + .arg(prompt), + )?; + let codex_pane = stdout_text(&start_output).trim().to_string(); + anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); + + wait_for_capture_contains( + &codex_pane, + "resize reflow sentinel", + Duration::from_secs(/*secs*/ 15), + )?; + wait_for_capture_contains( + &codex_pane, + "gpt-5.4 default", + Duration::from_secs(/*secs*/ 15), + )?; + let draft = "Notice where we are here in terms of y location."; + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("-l") + .arg(draft), + )?; + let baseline_capture = + wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; + let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; + let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") + .context("history row before split")?; + + let split_output = checked_output( + Command::new("tmux") + .arg("split-window") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-h") + .arg("-l") + .arg("40") + .arg("-t") + .arg(&codex_pane) + .arg("sleep") + .arg("30"), + )?; + let split_pane = stdout_text(&split_output).trim().to_string(); + + sleep(Duration::from_millis(/*millis*/ 750)); + check( + Command::new("tmux") + .arg("kill-pane") + .arg("-t") + .arg(&split_pane), + )?; + + sleep(Duration::from_millis(/*millis*/ 1_000)); + let restored_capture = capture_pane(&codex_pane)?; + let restored_row = + last_composer_row(&restored_capture).context("composer row after width restore")?; + let restored_history_row = first_row_containing(&restored_capture, "resize reflow sentinel") + .context("history row after width restore")?; + anyhow::ensure!( + restored_row == baseline_row, + "composer row drifted after width restore: baseline={baseline_row}, \ + restored={restored_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + anyhow::ensure!( + restored_history_row == baseline_history_row, + "history row drifted after width restore: baseline={baseline_history_row}, \ + restored={restored_history_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] +async fn tmux_scrolled_composer_resize_preserves_visible_draft_text() -> Result<()> { + if cfg!(windows) { + return Ok(()); + } + skip_if_no_network!(Ok(())); + if Command::new("tmux").arg("-V").output().is_err() { + eprintln!("skipping resize smoke because tmux is unavailable"); + return Ok(()); + } + + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex = codex_binary(&repo_root)?; + let codex_home = tempdir()?; + let server = MockServer::start().await; + let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); + write_config(codex_home.path(), &repo_root)?; + write_auth(codex_home.path())?; + + let session_name = format!("codex-resize-scrolled-composer-{}", std::process::id()); + let _session = TmuxSession { + name: session_name.clone(), + }; + + let start_output = checked_output( + Command::new("tmux") + .arg("new-session") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-x") + .arg("44") + .arg("-y") + .arg("14") + .arg("-s") + .arg(&session_name) + .arg("--") + .arg("env") + .arg(format!("CODEX_HOME={}", codex_home.path().display())) + .arg("OPENAI_API_KEY=dummy") + .arg(codex) + .arg("--model") + .arg("gpt-5.6-terra") + .arg("-c") + .arg("analytics.enabled=false") + .arg("-c") + .arg(&openai_base_url_config) + .arg("--no-alt-screen") + .arg("-C") + .arg(&repo_root), + )?; + let codex_pane = stdout_text(&start_output).trim().to_string(); + anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); + wait_for_capture_contains( + &codex_pane, + "gpt-5.6-terra", + Duration::from_secs(/*secs*/ 15), + )?; + + for index in 1..=9 { + let line = format!("probe-{index:02} clean words"); + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("-l") + .arg(line), + )?; + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("C-j"), + )?; + } + + let final_line = "q".repeat(/*n*/ 41); + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("-l") + .arg(&final_line), + )?; + let baseline = + wait_for_capture_contains(&codex_pane, &final_line, Duration::from_secs(/*secs*/ 15))?; + + for (phase, width, height, minimum_visible_rows) in + [("narrowed", "28", "9", 2), ("restored", "44", "14", 6)] + { + check( + Command::new("tmux") + .arg("resize-window") + .arg("-t") + .arg(&session_name) + .arg("-x") + .arg(width) + .arg("-y") + .arg(height), + )?; + sleep(Duration::from_millis(/*millis*/ 350)); + let capture = capture_pane(&codex_pane)?; + let visible_rows = capture + .lines() + .filter_map(|line| line.find("probe-").map(|start| (line, start))) + .collect::>(); + + anyhow::ensure!( + visible_rows.len() >= minimum_visible_rows, + "{phase} composer exposes too few draft rows: expected at least \ + {minimum_visible_rows}, found {}\nbaseline:\n{baseline}\ncapture:\n{capture}", + visible_rows.len() + ); + + for (line, start) in visible_rows { + let draft = &line[start..]; + let row_number = draft + .get(6..8) + .with_context(|| format!("malformed draft row after {phase}: {draft}"))?; + let expected = format!("probe-{row_number} clean words"); + let prefix = line[..start].trim(); + anyhow::ensure!( + draft.starts_with(&expected) && (prefix.is_empty() || prefix == "›"), + "{phase} resize left stale terminal content in the composer: \ + expected {expected:?}, found {line:?}\nbaseline:\n{baseline}\ncapture:\n{capture}" + ); + } + } + + Ok(()) +} + +async fn run_repeated_resize_smoke() -> Result<()> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex = codex_binary(&repo_root)?; + let codex_home = tempdir()?; + let server = MockServer::start().await; + let _response_mock = responses::mount_sse_once(&server, resize_reflow_sse()).await; + let openai_base_url_config = format!("openai_base_url=\"{}/v1\"", server.uri()); + write_config(codex_home.path(), &repo_root)?; + write_auth(codex_home.path())?; + + let session_name = format!("codex-resize-repeat-{}", std::process::id()); + let _session = TmuxSession { + name: session_name.clone(), + }; + + let prompt = "Send me a large paragraph of text for testing."; + let start_output = checked_output( + Command::new("tmux") + .arg("new-session") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-x") + .arg("120") + .arg("-y") + .arg("40") + .arg("-s") + .arg(&session_name) + .arg("--") + .arg("env") + .arg(format!("CODEX_HOME={}", codex_home.path().display())) + .arg("OPENAI_API_KEY=dummy") + .arg(codex) + .arg("-c") + .arg("analytics.enabled=false") + .arg("-c") + .arg(&openai_base_url_config) + .arg("--no-alt-screen") + .arg("-C") + .arg(&repo_root) + .arg(prompt), + )?; + let codex_pane = stdout_text(&start_output).trim().to_string(); + anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); + + wait_for_capture_contains( + &codex_pane, + "resize reflow sentinel", + Duration::from_secs(/*secs*/ 15), + )?; + wait_for_capture_contains( + &codex_pane, + "gpt-5.4 default", + Duration::from_secs(/*secs*/ 15), + )?; + let draft = "Notice where we are here in terms of y location."; + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("-l") + .arg(draft), + )?; + let baseline_capture = + wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; + let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; + let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") + .context("history row before split")?; + + for cycle in 1..=3 { + let split_output = checked_output( + Command::new("tmux") + .arg("split-window") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-v") + .arg("-l") + .arg("12") + .arg("-t") + .arg(&codex_pane) + .arg("sleep") + .arg("30"), + )?; + let split_pane = stdout_text(&split_output).trim().to_string(); + + sleep(Duration::from_millis(/*millis*/ 250)); + check( + Command::new("tmux") + .arg("kill-pane") + .arg("-t") + .arg(&split_pane), + )?; + + sleep(Duration::from_millis(/*millis*/ 500)); + let restored_capture = capture_pane(&codex_pane)?; + let restored_row = last_composer_row(&restored_capture) + .with_context(|| format!("composer row after resize cycle {cycle}"))?; + let restored_history_row = + first_row_containing(&restored_capture, "resize reflow sentinel") + .with_context(|| format!("history row after resize cycle {cycle}"))?; + anyhow::ensure!( + restored_row == baseline_row, + "composer row drifted after resize cycle {cycle}: baseline={baseline_row}, \ + restored={restored_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + anyhow::ensure!( + restored_history_row == baseline_history_row, + "history row drifted after resize cycle {cycle}: baseline={baseline_history_row}, \ + restored={restored_history_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + } + + Ok(()) +} + +struct TmuxSession { + name: String, +} + +impl Drop for TmuxSession { + fn drop(&mut self) { + let _ = Command::new("tmux") + .arg("kill-session") + .arg("-t") + .arg(&self.name) + .output(); + } +} + +fn codex_binary(repo_root: &Path) -> Result { + if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { + return Ok(path); + } + + let fallback = repo_root.join("codex-rs/target/debug/codex"); + anyhow::ensure!( + fallback.is_file(), + "codex binary is unavailable; run `cargo build -p codex-cli` first" + ); + Ok(fallback) +} + +fn write_config(codex_home: &Path, repo_root: &Path) -> Result<()> { + let repo_root_display = repo_root.display(); + let config = format!( + r#"model = "gpt-5.4" +model_provider = "openai" +suppress_unstable_features_warning = true + +[projects."{repo_root_display}"] +trust_level = "trusted" +"# + ); + std::fs::write(codex_home.join("config.toml"), config)?; + Ok(()) +} + +fn write_auth(codex_home: &Path) -> Result<()> { + std::fs::write( + codex_home.join("auth.json"), + r#"{"OPENAI_API_KEY":"dummy","tokens":null,"last_refresh":null}"#, + )?; + Ok(()) +} + +fn resize_reflow_sse() -> String { + let text = "resize reflow sentinel says hi. This paragraph is intentionally long enough to exercise terminal wrapping, scrollback redraw, and pane resize behavior without requiring a live model response. It includes enough ordinary prose to wrap across several rows in a narrow tmux pane, then keep going so repeated split and restore cycles have visible history above the composer. If a resize path accidentally inserts blank rows or anchors the viewport lower on each pass, the composer row will drift after the pane returns to its original height."; + responses::sse(vec![ + responses::ev_response_created("resp-resize-smoke"), + responses::ev_assistant_message("msg-resize-smoke", text), + responses::ev_completed("resp-resize-smoke"), + ]) +} + +fn wait_for_capture_contains(pane: &str, needle: &str, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut last_capture = String::new(); + while Instant::now() < deadline { + last_capture = capture_pane(pane)?; + if last_capture.contains(needle) { + return Ok(last_capture); + } + sleep(Duration::from_millis(/*millis*/ 100)); + } + + anyhow::bail!("timed out waiting for {needle:?}; last capture:\n{last_capture}"); +} + +fn capture_pane(pane: &str) -> Result { + let output = output( + Command::new("tmux") + .arg("capture-pane") + .arg("-p") + .arg("-t") + .arg(pane), + )?; + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn last_composer_row(capture: &str) -> Option { + capture + .lines() + .enumerate() + .filter_map(|(index, line)| { + if line.trim_start().starts_with('\u{203a}') { + Some(index) + } else { + None + } + }) + .last() +} + +fn first_row_containing(capture: &str, needle: &str) -> Option { + capture + .lines() + .enumerate() + .find_map(|(index, line)| line.contains(needle).then_some(index)) +} + +fn check(command: &mut Command) -> Result<()> { + checked_output(command)?; + Ok(()) +} + +fn checked_output(command: &mut Command) -> Result { + let output = output(command)?; + anyhow::ensure!( + output.status.success(), + "command failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(output) +} + +fn output(command: &mut Command) -> Result { + command + .output() + .with_context(|| format!("failed to run {command:?}")) +} + +fn stdout_text(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} diff --git a/codex-rs/tui/tests/suite/status_indicator.rs b/codex-rs/tui/tests/suite/status_indicator.rs new file mode 100644 index 0000000000000000000000000000000000000000..62f190d269f136cf679400dbb901e72a27172c1f --- /dev/null +++ b/codex-rs/tui/tests/suite/status_indicator.rs @@ -0,0 +1,24 @@ +//! Regression test: ensure that `StatusIndicatorWidget` sanitises ANSI escape +//! sequences so that no raw `\x1b` bytes are written into the backing +//! buffer. Rendering logic is tricky to unit‑test end‑to‑end, therefore we +//! verify the *public* contract of `ansi_escape_line()` which the widget now +//! relies on. + +use codex_ansi_escape::ansi_escape_line; + +#[test] +fn ansi_escape_line_strips_escape_sequences() { + let text_in_ansi_red = "\x1b[31mRED\x1b[0m"; + + // The returned line must contain three printable glyphs and **no** raw + // escape bytes. + let line = ansi_escape_line(text_in_ansi_red); + + let combined: String = line + .spans + .iter() + .map(|span| span.content.to_string()) + .collect(); + + assert_eq!(combined, "RED"); +} diff --git a/codex-rs/tui/tests/suite/vt100_history.rs b/codex-rs/tui/tests/suite/vt100_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..6243b2d720ee3535ea196cbdef1b818d70f34996 --- /dev/null +++ b/codex-rs/tui/tests/suite/vt100_history.rs @@ -0,0 +1,164 @@ +use crate::test_backend::VT100Backend; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; + +// Small helper macro to assert a collection contains an item with a clearer +// failure message. +macro_rules! assert_contains { + ($collection:expr, $item:expr $(,)?) => { + assert!( + $collection.contains(&$item), + "Expected {:?} to contain {:?}", + $collection, + $item + ); + }; + ($collection:expr, $item:expr, $($arg:tt)+) => { + assert!($collection.contains(&$item), $($arg)+); + }; +} + +struct TestScenario { + term: codex_tui::Terminal, +} + +impl TestScenario { + fn new(width: u16, height: u16, viewport: Rect) -> Self { + let backend = VT100Backend::new(width, height); + let mut term = + codex_tui::Terminal::with_options(backend).expect("failed to construct terminal"); + term.set_viewport_area(viewport); + Self { term } + } + + fn run_insert(&mut self, lines: Vec>) { + codex_tui::insert_history_lines(&mut self.term, lines) + .expect("Failed to insert history lines in test"); + } +} + +#[test] +fn basic_insertion_no_wrap() { + // Screen of 20x6; viewport is the last row (height=1 at y=5) + let area = Rect::new( + /*x*/ 0, /*y*/ 5, /*width*/ 20, /*height*/ 1, + ); + let mut scenario = TestScenario::new(/*width*/ 20, /*height*/ 6, area); + + let lines = vec!["first".into(), "second".into()]; + scenario.run_insert(lines); + let rows = scenario.term.backend().vt100().screen().contents(); + assert_contains!(rows, String::from("first")); + assert_contains!(rows, String::from("second")); +} + +#[test] +fn long_token_wraps() { + let area = Rect::new( + /*x*/ 0, /*y*/ 5, /*width*/ 20, /*height*/ 1, + ); + let mut scenario = TestScenario::new(/*width*/ 20, /*height*/ 6, area); + + let long = "A".repeat(45); // > 2 lines at width 20 + let lines = vec![long.clone().into()]; + scenario.run_insert(lines); + let screen = scenario.term.backend().vt100().screen(); + + // Count total A's on the screen + let mut count_a = 0usize; + for row in 0..6 { + for col in 0..20 { + if let Some(cell) = screen.cell(row, col) + && let Some(ch) = cell.contents().chars().next() + && ch == 'A' + { + count_a += 1; + } + } + } + + assert_eq!( + count_a, + long.len(), + "wrapped content did not preserve all characters" + ); +} + +#[test] +fn emoji_and_cjk() { + let area = Rect::new( + /*x*/ 0, /*y*/ 5, /*width*/ 20, /*height*/ 1, + ); + let mut scenario = TestScenario::new(/*width*/ 20, /*height*/ 6, area); + + let text = String::from("😀😀😀😀😀 你好世界"); + let lines = vec![text.clone().into()]; + scenario.run_insert(lines); + let rows = scenario.term.backend().vt100().screen().contents(); + for ch in text.chars().filter(|c| !c.is_whitespace()) { + assert!( + rows.contains(ch), + "missing character {ch:?} in reconstructed screen" + ); + } +} + +#[test] +fn mixed_ansi_spans() { + let area = Rect::new( + /*x*/ 0, /*y*/ 5, /*width*/ 20, /*height*/ 1, + ); + let mut scenario = TestScenario::new(/*width*/ 20, /*height*/ 6, area); + + let line = vec!["red".red(), "+plain".into()].into(); + scenario.run_insert(vec![line]); + let rows = scenario.term.backend().vt100().screen().contents(); + assert_contains!(rows, String::from("red+plain")); +} + +#[test] +fn cursor_restoration() { + let area = Rect::new( + /*x*/ 0, /*y*/ 5, /*width*/ 20, /*height*/ 1, + ); + let mut scenario = TestScenario::new(/*width*/ 20, /*height*/ 6, area); + + let lines = vec!["x".into()]; + scenario.run_insert(lines); + assert_eq!(scenario.term.last_known_cursor_pos, (0, 0).into()); +} + +#[test] +fn word_wrap_no_mid_word_split() { + // Screen of 40x10; viewport is the last row + let area = Rect::new( + /*x*/ 0, /*y*/ 9, /*width*/ 40, /*height*/ 1, + ); + let mut scenario = TestScenario::new(/*width*/ 40, /*height*/ 10, area); + + let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them."; + scenario.run_insert(vec![sample.into()]); + let joined = scenario.term.backend().vt100().screen().contents(); + assert!( + !joined.contains("bo\nth"), + "word 'both' should not be split across lines:\n{joined}" + ); +} + +#[test] +fn em_dash_and_space_word_wrap() { + // Repro from report: ensure we break before "inside", not mid-word. + let area = Rect::new( + /*x*/ 0, /*y*/ 9, /*width*/ 40, /*height*/ 1, + ); + let mut scenario = TestScenario::new(/*width*/ 40, /*height*/ 10, area); + + let sample = "Mara found an old key on the shore. Curious, she opened a tarnished box half-buried in sand—and inside lay a single, glowing seed."; + scenario.run_insert(vec![sample.into()]); + let joined = scenario.term.backend().vt100().screen().contents(); + assert!( + !joined.contains("insi\nde"), + "word 'inside' should not be split across lines:\n{joined}" + ); +} diff --git a/codex-rs/tui/tests/suite/vt100_live_commit.rs b/codex-rs/tui/tests/suite/vt100_live_commit.rs new file mode 100644 index 0000000000000000000000000000000000000000..e0375ad6c8ad97f2ca6102024c8617871eeda6aa --- /dev/null +++ b/codex-rs/tui/tests/suite/vt100_live_commit.rs @@ -0,0 +1,43 @@ +use crate::test_backend::VT100Backend; +use ratatui::layout::Rect; +use ratatui::text::Line; + +#[test] +fn live_001_commit_on_overflow() { + let backend = VT100Backend::new(/*width*/ 20, /*height*/ 6); + let mut term = + codex_tui::Terminal::with_options(backend).expect("terminal should be constructed"); + let area = Rect::new( + /*x*/ 0, /*y*/ 5, /*width*/ 20, /*height*/ 1, + ); + term.set_viewport_area(area); + + // Build 5 explicit rows at width 20. + let mut rb = codex_tui::RowBuilder::new(/*target_width*/ 20); + rb.push_fragment("one\n"); + rb.push_fragment("two\n"); + rb.push_fragment("three\n"); + rb.push_fragment("four\n"); + rb.push_fragment("five\n"); + + // Keep the last 3 in the live ring; commit the first 2. + let commit_rows = rb.drain_commit_ready(/*max_keep*/ 3); + let lines: Vec> = commit_rows.into_iter().map(|r| r.text.into()).collect(); + + codex_tui::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + + let screen = term.backend().vt100().screen(); + + // The words "one" and "two" should appear above the viewport. + let joined = screen.contents(); + assert!( + joined.contains("one"), + "expected committed 'one' to be visible\n{joined}" + ); + assert!( + joined.contains("two"), + "expected committed 'two' to be visible\n{joined}" + ); + // The last three (three,four,five) remain in the live ring, not committed here. +} diff --git a/codex-rs/tui/tests/suite/worktree_stack.rs b/codex-rs/tui/tests/suite/worktree_stack.rs new file mode 100644 index 0000000000000000000000000000000000000000..a86ec7f8b0ca336fc63378665d0440c0136bcd67 --- /dev/null +++ b/codex-rs/tui/tests/suite/worktree_stack.rs @@ -0,0 +1,181 @@ +//! Exercises stack-sensitive session transitions through the real TUI event loop. + +use super::focus_palette::PtyCodex; +use super::focus_palette::write_test_config; +use anyhow::Result; +use anyhow::ensure; +use codex_worktree::ManagedWorktree; +use codex_worktree::WorktreeManager; +use codex_worktree::WorktreeSettings; +use core_test_support::responses; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::Duration; +use std::time::Instant; +use wiremock::matchers::body_string_contains; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Result<()> { + let repository = tempfile::tempdir_in("/tmp")?; + let root = repository.path().canonicalize()?; + let status = Command::new("git") + .args(["init", "-q", "-b", "main"]) + .arg(&root) + .status()?; + ensure!(status.success(), "initialize test repository"); + fs::write(root.join("README.md"), "stack transition test\n")?; + ensure!( + Command::new("git") + .args(["-C"]) + .arg(&root) + .args(["add", "README.md"]) + .status()? + .success(), + "stage test repository" + ); + ensure!( + Command::new("git") + .args(["-C"]) + .arg(&root) + .args([ + "-c", + "user.name=Codex Test", + "-c", + "user.email=test@example.invalid" + ]) + .args(["commit", "--no-gpg-sign", "-qm", "initial"]) + .status()? + .success(), + "commit test repository" + ); + + let codex_home = tempfile::tempdir_in("/tmp")?; + write_test_config(codex_home.path(), &root)?; + let server = responses::start_mock_server().await; + let config_path = codex_home.path().join("config.toml"); + let config = fs::read_to_string(&config_path)? + .replace("model_provider = \"openai\"", "model_provider = \"test\""); + fs::write( + &config_path, + format!( + "{config}\n[model_providers.test]\nname = \"Mock\"\nbase_url = \"{}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nsupports_websockets = false\n", + server.uri() + ), + )?; + let _reply = responses::mount_sse_once_match( + &server, + body_string_contains("marker requested by this test"), + responses::sse(vec![ + responses::ev_assistant_message("saved", "STACK_SAVED_HISTORY"), + responses::ev_completed("saved-response"), + ]), + ) + .await; + + let worktrees = WorktreeManager::new(WorktreeSettings::for_cli( + codex_home.path(), + /*desktop*/ None, + )?); + // The launcher gives codex-main and Tokio workers explicit 16 MiB stacks. + let mut terminal = PtyCodex::start( + &root, + codex_home, + &[ + "-c", + "features.worktrees=true", + "-c", + "features.shell_snapshot=false", + "Reply with the marker requested by this test.", + ], + )?; + terminal.wait_for_startup()?; + terminal.wait_for_screen("STACK_SAVED_HISTORY")?; + terminal.wait_for_screen("Ask Codex to do anything")?; + + submit(&mut terminal, "/side")?; + terminal.wait_for_screen("Side from main thread")?; + terminal.ensure_running()?; + terminal.write_input(b"\x03")?; + terminal.wait_for_screen("STACK_SAVED_HISTORY")?; + + submit(&mut terminal, "/resume")?; + terminal.wait_for_screen("Resume a previous session")?; + terminal.ensure_running()?; + terminal.write_input(b"\x1b")?; + terminal.wait_for_screen("Ask Codex to do anything")?; + + submit(&mut terminal, "/worktree")?; + terminal.wait_for_screen("Continue current conversation")?; + terminal.write_input(b"1")?; + let forked = wait_for_owned_worktrees(&mut terminal, &worktrees, &root, /*count*/ 1)?; + let fork_bucket = forked[0].root.parent().expect("bucket"); + terminal.wait_for_screen(&format!( + "/{}/", + fork_bucket.file_name().unwrap().to_string_lossy() + ))?; + terminal.ensure_running()?; + + submit(&mut terminal, &format!("/cd {}", root.display()))?; + terminal.wait_for_screen(&format!("Working directory changed to: {}", root.display()))?; + terminal.ensure_running()?; + + submit(&mut terminal, "/worktree")?; + terminal.wait_for_screen("Start new conversation")?; + terminal.write_input(b"2")?; + let created = wait_for_owned_worktrees(&mut terminal, &worktrees, &root, /*count*/ 2)?; + ensure!( + created + .iter() + .any(|checkout| checkout.root == forked[0].root) + ); + let second = created + .iter() + .find(|checkout| checkout.root != forked[0].root) + .expect("second checkout"); + ensure!( + worktrees.owner(&forked[0].root)? != worktrees.owner(&second.root)?, + "new worktree reused the forked thread owner" + ); + let second_bucket = second.root.parent().expect("bucket"); + terminal.wait_for_screen(&format!( + "/{}/", + second_bucket.file_name().unwrap().to_string_lossy() + ))?; + terminal.ensure_running()?; + Ok(()) +} + +fn submit(terminal: &mut PtyCodex, input: &str) -> Result<()> { + terminal.write_input(input.as_bytes())?; + terminal.wait_for_screen(input)?; + // Bulk PTY writes resemble a paste; wait out the Enter suppression window. + std::thread::sleep(Duration::from_millis(/*millis*/ 250)); + terminal.write_input(b"\r")?; + Ok(()) +} + +fn wait_for_owned_worktrees( + terminal: &mut PtyCodex, + manager: &WorktreeManager, + source: &Path, + count: usize, +) -> Result> { + let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30); + while Instant::now() < deadline { + terminal.read_output(Duration::from_millis(/*millis*/ 50))?; + let checkouts = manager.list(source).unwrap_or_default(); + if checkouts.len() == count + && checkouts + .iter() + .all(|checkout| manager.owner(&checkout.root).ok().flatten().is_some()) + { + return Ok(checkouts); + } + terminal.ensure_running()?; + } + anyhow::bail!( + "did not bind {count} managed checkouts; screen:\n{}", + terminal.screen_contents() + ) +} diff --git a/codex-rs/voice-host/src/audio_sink.rs b/codex-rs/voice-host/src/audio_sink.rs new file mode 100644 index 0000000000000000000000000000000000000000..33f0062bbbbe19a35bf7f87102476b019d54bb59 --- /dev/null +++ b/codex-rs/voice-host/src/audio_sink.rs @@ -0,0 +1,136 @@ +//! Stock GstAudioSink scheduling with a bounded, epoch-owned CPAL writer. +//! Native resets outside an owner-initiated teardown fail the session closed. + +use super::playback::PlaybackWriter; +use audio::prelude::*; +use audio::subclass::prelude::*; +use gst::glib; +use gstreamer as gst; +use gstreamer_audio as audio; +use std::sync::OnceLock; + +mod imp { + use super::*; + #[derive(Default)] + pub struct Sink { + pub(super) writer: OnceLock, + #[cfg(test)] + first_event: OnceLock<&'static str>, + } + #[glib::object_subclass] + impl ObjectSubclass for Sink { + const NAME: &'static str = "CodexPrivateAudioSink"; + type Type = super::Sink; + type ParentType = audio::AudioSink; + } + impl ObjectImpl for Sink {} + impl GstObjectImpl for Sink {} + impl ElementImpl for Sink { + fn metadata() -> Option<&'static gst::subclass::ElementMetadata> { + static META: OnceLock = OnceLock::new(); + Some(META.get_or_init(|| { + gst::subclass::ElementMetadata::new( + "Private audio", + "Sink/Audio", + "Owned speaker output", + "OpenAI", + ) + })) + } + fn pad_templates() -> &'static [gst::PadTemplate] { + static PADS: OnceLock> = OnceLock::new(); + PADS.get_or_init(|| { + gst::PadTemplate::new( + "sink", + gst::PadDirection::Sink, + gst::PadPresence::Always, + &gst::Caps::builder("audio/x-raw") + .field("format", "F32LE") + .field("layout", "interleaved") + .field("channels", 1i32) + .field( + "rate", + gst::IntRange::::new(/*min*/ 8000, /*max*/ 384000), + ) + .build(), + ) + .into_iter() + .collect() + }) + } + } + impl BaseSinkImpl for Sink {} + impl AudioBaseSinkImpl for Sink {} + impl AudioSinkImpl for Sink { + fn prepare(&self, spec: &mut audio::AudioRingBufferSpec) -> Result<(), gst::LoggableError> { + let writer = self + .writer + .get() + .ok_or_else(|| gst::loggable_error!(gst::CAT_RUST, "speaker not bound"))?; + let info = spec.audio_info(); + if info.rate() != writer.rate() + || info.channels() != 1 + || info.format() != audio::AudioFormat::F32le + || info.layout() != audio::AudioLayout::Interleaved + { + return Err(gst::loggable_error!( + gst::CAT_RUST, + "unsupported speaker format" + )); + } + spec.set_segsize((writer.rate() / 100 * 4) as i32); + spec.set_segtotal(4); + spec.set_latency_time(10_000); + spec.set_buffer_time(40_000); + Ok(()) + } + fn unprepare(&self) -> Result<(), gst::LoggableError> { + Ok(()) + } + fn write(&self, bytes: &[u8]) -> Result { + let writer = self + .writer + .get() + .ok_or_else(|| gst::loggable_error!(gst::CAT_RUST, "speaker not bound"))?; + writer.write(bytes).map(|n| n as i32).map_err(|_error| { + #[cfg(test)] + self.first_event.get_or_init(|| { + eprintln!("native sink first event: {_error}"); + _error + }); + writer.fail_if_current(); + gst::loggable_error!(gst::CAT_RUST, "speaker write failed") + }) + } + fn delay(&self) -> u32 { + self.writer.get().map_or(0, PlaybackWriter::delay) + } + fn reset(&self) { + if let Some(writer) = self.writer.get() { + #[cfg(test)] + self.first_event.get_or_init(|| { + eprintln!("native sink first event: reset"); + "reset" + }); + writer.fail_if_current(); + } + } + } +} + +glib::wrapper! { + pub struct Sink(ObjectSubclass) @extends audio::AudioSink, audio::AudioBaseSink, audio::gst_base::BaseSink, gst::Element, gst::Object; +} + +impl Sink { + pub(super) fn new(writer: PlaybackWriter) -> Self { + let sink: Self = glib::Object::new(); + assert!(sink.imp().writer.set(writer).is_ok()); + sink.set_provide_clock(false); + sink.set_property_from_str("slave-method", "none"); + sink.set_sync(false); + sink.set_async(false); + sink.set_enable_last_sample(false); + sink + } +} diff --git a/codex-rs/voice-host/src/audio_track.rs b/codex-rs/voice-host/src/audio_track.rs new file mode 100644 index 0000000000000000000000000000000000000000..1525465e7db7af9238309f94e5b49c638928f4cd --- /dev/null +++ b/codex-rs/voice-host/src/audio_track.rs @@ -0,0 +1,162 @@ +//! A single Opus RTP track. Muted capture sends generated silence to keep the peer alive; +//! elapsed time still advances its clock without inventing packet loss. + +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use rtc::media::Sample; +use rtc::rtp_transceiver::rtp_sender::RTCRtpCodec; +use rtc::rtp_transceiver::rtp_sender::RTCRtpCodecParameters; +use rtc::rtp_transceiver::rtp_sender::RTCRtpCodingParameters; +use rtc::rtp_transceiver::rtp_sender::RTCRtpEncodingParameters; +use rtc::rtp_transceiver::rtp_sender::RtpCodecKind; +use webrtc::media_stream::MediaStreamTrack; +use webrtc::media_stream::track_local::static_sample::TrackLocalStaticSample; +use webrtc::peer_connection::MediaEngine; + +pub(crate) const OPUS_PAYLOAD_TYPE: u8 = 111; +pub(crate) const SEND_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 100); + +pub(crate) struct EncodedAudio { + pub(crate) data: Vec, + pub(crate) at: Instant, +} + +pub(crate) struct AudioTrack { + pub(crate) track: Arc, + ssrc: u32, + end: Option, + silence: Option>, +} + +impl AudioTrack { + pub(crate) fn new() -> Result<(MediaEngine, Self), &'static str> { + let ssrc = rand::random(); + let codec = RTCRtpCodec { + mime_type: "audio/opus".into(), + clock_rate: 48_000, + channels: 2, + sdp_fmtp_line: "minptime=10;useinbandfec=1".into(), + rtcp_feedback: vec![], + }; + let mut media = MediaEngine::default(); + media + .register_codec( + RTCRtpCodecParameters { + rtp_codec: codec.clone(), + payload_type: OPUS_PAYLOAD_TYPE, + }, + RtpCodecKind::Audio, + ) + .map_err(|_| "failed to register voice codec")?; + let track = TrackLocalStaticSample::new(MediaStreamTrack::new( + "realtime".into(), + format!("audio-{ssrc}"), + "microphone".into(), + RtpCodecKind::Audio, + vec![RTCRtpEncodingParameters { + rtp_coding_parameters: RTCRtpCodingParameters { + ssrc: Some(ssrc), + ..Default::default() + }, + codec, + active: true, + ..Default::default() + }], + )) + .map_err(|_| "failed to create voice track")?; + Ok(( + media, + Self { + track: Arc::new(track), + ssrc, + end: None, + silence: None, + }, + )) + } + + /// Send only synthetic silence while muted, never a device or processing buffer. + /// Reuse the encoded packet and pace it by the same RTP clock as live capture. + #[cfg(any( + target_os = "macos", + all(target_os = "linux", target_env = "gnu"), + all(windows, target_env = "msvc") + ))] + pub(crate) async fn send_muted_silence(&mut self, at: Instant) -> Result<(), &'static str> { + if self.end.is_some_and(|end| at < end) { + return Ok(()); + } + if self.silence.is_none() { + let mut encoder = opus::Encoder::new( + /*sample_rate*/ 48_000, + opus::Channels::Mono, + opus::Application::Voip, + ) + .map_err(|_| "failed to create voice silence encoder")?; + let mut data = vec![0; 1275]; + let len = encoder + .encode_float(&[0.0; 960], &mut data) + .map_err(|_| "failed to encode voice silence")?; + data.truncate(len); + self.silence = Some(data); + } + self.send(EncodedAudio { + data: self + .silence + .as_ref() + .ok_or("voice silence unavailable")? + .clone(), + at, + }) + .await + } + + pub(crate) async fn send(&mut self, frame: EncodedAudio) -> Result<(), &'static str> { + tokio::time::timeout(SEND_TIMEOUT, async { + let mut gap = self + .end + .map(|end| frame.at.saturating_duration_since(end)) + .unwrap_or_default(); + while !gap.is_zero() { + let duration = gap.min(Duration::from_secs(/*secs*/ 3600)); + self.track + .write_sample( + self.ssrc, + /*payload_type*/ OPUS_PAYLOAD_TYPE, + &Sample { + duration, + ..Default::default() + }, + &[], + ) + .await + .map_err(|_| "failed to advance voice clock")?; + gap -= duration; + } + let duration = Duration::from_millis(/*millis*/ 20); + self.track + .write_sample( + self.ssrc, + /*payload_type*/ OPUS_PAYLOAD_TYPE, + &Sample { + data: frame.data.into(), + duration, + ..Default::default() + }, + &[], + ) + .await + .map_err(|_| "failed to send voice audio")?; + self.end = Some(self.end.unwrap_or(frame.at).max(frame.at) + duration); + Ok(()) + }) + .await + .map_err(|_| "voice audio sender stalled")? + } +} + +#[cfg(test)] +#[path = "audio_track_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/audio_track_tests.rs b/codex-rs/voice-host/src/audio_track_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..1be43deac2c0edff12f17b01e1d487113dbae50d --- /dev/null +++ b/codex-rs/voice-host/src/audio_track_tests.rs @@ -0,0 +1,17 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn timestamp_jitter_does_not_accumulate_but_mute_advances_the_clock() { + let (_, mut track) = AudioTrack::new().unwrap(); + let start = Instant::now(); + // Empty samples exercise the real packetizer without requiring a bound network peer. + for tick in 0..500 { + let at = start + Duration::from_millis(tick * 20 + tick % 2); + track.send(EncodedAudio { data: vec![], at }).await.unwrap(); + } + assert_eq!(track.end, Some(start + Duration::from_millis(10_001))); + let at = start + Duration::from_secs(30); + track.send(EncodedAudio { data: vec![], at }).await.unwrap(); + assert_eq!(track.end, Some(at + Duration::from_millis(20))); +} diff --git a/codex-rs/voice-host/src/capture_worker.rs b/codex-rs/voice-host/src/capture_worker.rs new file mode 100644 index 0000000000000000000000000000000000000000..dc6f8ef47da2869dd910f1ec50e1ccb2511d2aea --- /dev/null +++ b/codex-rs/voice-host/src/capture_worker.rs @@ -0,0 +1,138 @@ +//! Pumps bounded device queues through capture processing and the outgoing audio track. +//! Devices retain real stream ownership; all queued and partial capture obey mute generations. +//! Muted sessions send generated Opus silence independently of device capture. +//! Retain at most one encoded batch and return between packets so controls can take priority. + +use std::collections::VecDeque; +use std::io; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Instant; + +use super::MAX_CAPTURE_AGE; +use super::buffers::Buffers; +use super::processing; +use crate::service_failure::ServiceFailure; + +pub(super) struct CaptureWorker { + pub(super) buffers: Arc, + pub(super) processor: processing::Processor, + pub(super) pending: VecDeque, +} + +impl CaptureWorker { + pub(super) fn set_controls( + &mut self, + controls: codex_realtime_webrtc::AudioControls, + ) -> io::Result<()> { + // Retire the old sink before rebuilding microphone processing. + self.buffers + .set_speaker_disabled(controls.speaker_suppressed)?; + let previous = self.buffers.microphone.load(Ordering::Acquire); + Buffers::set_disabled(&self.buffers.microphone, controls.microphone_muted)?; + if previous != self.buffers.microphone.load(Ordering::Acquire) { + self.pending.clear(); + self.processor.reset().map_err(io::Error::other)?; + } + Ok(()) + } + + pub(super) async fn service( + &mut self, + audio: &mut crate::audio_track::AudioTrack, + now: impl Fn() -> Instant, + ) -> io::Result { + // Some backends start callbacks during stream construction, before open returns. + self.buffers.serviced.store(true, Ordering::Release); + let generation = self.buffers.microphone.load(Ordering::Acquire); + if self.buffers.render_dropped.swap(false, Ordering::AcqRel) { + self.processor.reset_render(); + for _ in 0..self.buffers.rendered.capacity() { + if self.buffers.rendered.pop().is_none() { + break; + } + } + } + if self.buffers.capture_dropped.swap(false, Ordering::AcqRel) { + self.pending.clear(); + self.processor.reset().map_err(io::Error::other)?; + for _ in 0..self.buffers.capture.capacity() { + if self.buffers.capture.pop().is_none() { + break; + } + } + } + let mut render_stale = false; + for _ in 0..self.buffers.rendered.capacity() { + let Some(frame) = self.buffers.rendered.pop() else { + break; + }; + if frame.at.elapsed() > MAX_CAPTURE_AGE { + if !render_stale { + self.processor.reset_render(); + render_stale = true; + } + continue; + } + render_stale = false; + self.processor + .render(&frame) + .map_err(|_| io::Error::other(ServiceFailure::Render))?; + } + if self.pending.is_empty() { + let mut capture_stale = false; + for _ in 0..self.buffers.capture.capacity() { + let Some(frame) = self.buffers.capture.pop() else { + break; + }; + if generation.is_multiple_of(2) && frame.generation == generation { + if now().saturating_duration_since(frame.at) > processing::MAX_PROCESSING_DELAY + { + self.pending.clear(); + if !capture_stale { + self.processor.reset().map_err(io::Error::other)?; + capture_stale = true; + } + continue; + } + capture_stale = false; + let frames = self.processor.capture(&frame, &now); + match frames { + Ok(frames) => self.pending.extend(frames), + Err(processing::PROCESSING_LATE) => { + self.pending.clear(); + self.processor.reset().map_err(io::Error::other)?; + } + Err(_) => return Err(io::Error::other(ServiceFailure::Capture)), + } + } + } + } + if self.buffers.failed.load(Ordering::Acquire) { + return Err(io::Error::other(ServiceFailure::Device)); + } + let Some(frame) = self.pending.pop_front() else { + if !generation.is_multiple_of(2) { + audio + .send_muted_silence(now()) + .await + .map_err(|_| io::Error::other(ServiceFailure::Send))?; + } + return Ok(0); + }; + if now().saturating_duration_since(frame.at) > processing::MAX_PROCESSING_DELAY { + self.pending.clear(); + self.processor.reset().map_err(io::Error::other)?; + return Ok(0); + } + audio + .send(frame) + .await + .map_err(|_| io::Error::other(ServiceFailure::Send))?; + Ok(1) + } +} + +#[cfg(test)] +#[path = "capture_worker_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/capture_worker_tests.rs b/codex-rs/voice-host/src/capture_worker_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..17e739b0bfba057322ced8db294471a9f30844ec --- /dev/null +++ b/codex-rs/voice-host/src/capture_worker_tests.rs @@ -0,0 +1,431 @@ +use super::super::buffers::BLOCK; +use super::super::buffers::Frame; +use super::*; +use crate::audio_track::AudioTrack; +use crate::transport::Transport; +use codex_realtime_webrtc::AudioControls; +use futures::future::BoxFuture; +use pretty_assertions::assert_eq; +use rtc::interceptor::Interceptor; +use rtc::interceptor::NoopInterceptor; +use rtc::interceptor::Packet; +use rtc::interceptor::StreamInfo; +use rtc::interceptor::TaggedPacket; +use rtc::interceptor::interceptor; +use rtc::sansio; +use rtc::shared::error::Error; +use std::time::Duration; +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio::time::timeout; +use webrtc::peer_connection::PeerConnection; +use webrtc::peer_connection::PeerConnectionBuilder; +use webrtc::peer_connection::PeerConnectionEventHandler; +use webrtc::peer_connection::RTCIceGatheringState; +use webrtc::peer_connection::RTCSessionDescription; + +// Consume remote RTP before the upstream track queue: receipt proves the packet +// traversed negotiation, encryption, the loopback socket, and remote decryption. +#[derive(Interceptor)] +struct RemoteRtp { + #[next] + next: NoopInterceptor, + sender: mpsc::Sender, +} + +#[interceptor] +impl RemoteRtp { + #[overrides] + fn handle_read(&mut self, message: TaggedPacket) -> Result<(), Self::Error> { + if let Packet::Rtp(packet) = message.message { + self.sender.try_send(packet).unwrap(); + } + Ok(()) + } +} + +struct Gathered(Arc); + +impl PeerConnectionEventHandler for Gathered { + fn on_ice_gathering_state_change<'a, 'async_trait>( + &'a self, + state: RTCIceGatheringState, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + if state == RTCIceGatheringState::Complete { + self.0.notify_one(); + } + }) + } +} + +// Synthetic callback output enters the same bounded queue as the real cpal +// callback. No hardware availability or physical microphone behavior is claimed. +fn enqueue(worker: &CaptureWorker, samples: usize, generation: u64, at: Instant) { + for offset in (0..samples).step_by(BLOCK) { + let mut frame = Frame { + samples: [0.0; BLOCK], + len: BLOCK.min(samples - offset), + at: at + Duration::from_secs_f64(offset as f64 / 48_000.0), + generation, + }; + for (index, sample) in frame.samples.iter_mut().enumerate() { + *sample = + (std::f32::consts::TAU * 440.0 * (offset + index) as f32 / 48_000.0).sin() * 0.25; + } + assert!(worker.buffers.capture.push(frame).is_ok()); + } +} + +#[test] +fn combined_mute_and_suppression_cancel_old_speaker_and_capture_work() { + let buffers = Arc::new(Buffers::new( + /*input_rate*/ 48_000, /*output_rate*/ 48_000, + )); + let port = super::super::playback::PlaybackPort::new(buffers.clone(), /*rate*/ 48_000); + let mut worker = CaptureWorker { + buffers: buffers.clone(), + processor: processing::Processor::new( + /*input_rate*/ 48_000, /*output_rate*/ 48_000, + ) + .unwrap(), + pending: VecDeque::new(), + }; + worker + .set_controls(AudioControls { + microphone_muted: false, + speaker_suppressed: false, + }) + .unwrap(); + worker.pending.push_back(crate::audio_track::EncodedAudio { + data: vec![1], + at: Instant::now(), + }); + // A failed speaker transition must not start microphone reset first. + buffers.speaker.store(u64::MAX, Ordering::Release); + assert!( + worker + .set_controls(AudioControls { + microphone_muted: true, + speaker_suppressed: false, + }) + .is_err() + ); + assert_eq!( + ( + buffers.speaker.load(Ordering::Acquire), + buffers.microphone.load(Ordering::Acquire), + worker.pending.len(), + ), + (u64::MAX, 2, 1) + ); + buffers.speaker.store(/*val*/ 2, Ordering::Release); + let writer = port.writer(); + worker + .set_controls(AudioControls { + microphone_muted: true, + speaker_suppressed: true, + }) + .unwrap(); + assert_eq!( + ( + buffers.speaker.load(Ordering::Acquire), + buffers.microphone.load(Ordering::Acquire), + worker.pending.len(), + writer.write(&[0; 4]), + ), + (3, 3, 0, Err("speaker writer cancelled")) + ); +} + +async fn receive_capture( + worker: &mut CaptureWorker, + local: &mut Transport, + received: &mut mpsc::Receiver, + now: Instant, +) -> Vec { + let mut count = 0; + loop { + let sent = worker.service(&mut local.audio, || now).await.unwrap(); + if sent == 0 { + break; + } + assert_eq!(sent, 1); + count += sent; + } + assert!(count > 0, "fresh capture must produce audio"); + let mut packets = Vec::new(); + let mut decoder = opus::Decoder::new(/*sample_rate*/ 48_000, opus::Channels::Mono).unwrap(); + let mut energy = 0.0_f32; + for _ in 0..count { + let packet = received.recv().await.unwrap(); + assert_eq!(packet.header.payload_type, 111); + let mut decoded = [0.0; 960]; + assert_eq!( + decoder + .decode_float(&packet.payload, &mut decoded, /*fec*/ false) + .unwrap(), + 960 + ); + assert!(decoded.iter().all(|sample| sample.is_finite())); + energy += decoded.iter().map(|sample| sample * sample).sum::(); + packets.push(packet); + } + assert!( + energy > 0.01, + "decoded microphone signal must not be silence" + ); + packets +} + +#[tokio::test] +async fn capture_reaches_remote_rtp_and_mute_discards_queued_and_partial_audio() { + timeout(Duration::from_secs(/*secs*/ 30), async { + let (sender, mut received) = mpsc::channel(/*buffer*/ 32); + let gathered = Arc::new(Notify::new()); + let (media, _) = AudioTrack::new().unwrap(); + let mut settings = webrtc::peer_connection::SettingEngine::default(); + settings.set_lite(/*lite*/ true); + let remote = PeerConnectionBuilder::new() + .with_media_engine(media) + .with_setting_engine(settings) + .with_handler(Arc::new(Gathered(gathered.clone()))) + .with_interceptor_registry(rtc::interceptor::Registry::from(RemoteRtp { + next: NoopInterceptor::new(), + sender, + })) + .with_udp_addrs(vec!["0.0.0.0:0"]) + .build() + .await + .unwrap(); + let mut local = Transport::new().await.unwrap(); + remote + .set_remote_description( + RTCSessionDescription::offer(local.offer().await.unwrap()).unwrap(), + ) + .await + .unwrap(); + let answer = remote.create_answer(/*options*/ None).await.unwrap(); + remote.set_local_description(answer).await.unwrap(); + gathered.notified().await; + local + .apply_answer(remote.local_description().await.unwrap().sdp) + .await + .unwrap(); + + let mut worker = CaptureWorker { + buffers: Arc::new(Buffers::new( + /*input_rate*/ 48_000, /*output_rate*/ 48_000, + )), + pending: Default::default(), + processor: processing::Processor::new( + /*input_rate*/ 48_000, /*output_rate*/ 44_100, + ) + .unwrap(), + }; + let unmuted = || AudioControls { + microphone_muted: false, + speaker_suppressed: false, + }; + let muted = || AudioControls { + microphone_muted: true, + speaker_suppressed: true, + }; + worker.set_controls(unmuted()).unwrap(); + let generation = worker.buffers.microphone.load(Ordering::Acquire); + // Freeze processing time so this transport test does not benchmark the CI worker. + // Processor tests separately exercise the real backlog cutoff. + let start = Instant::now(); + let mut now = start; + // Feed a distinct speaker tone through the real render resampler and APM + // before capture; this exercises a different output rate without hardware. + for offset in (0..2_646).step_by(BLOCK) { + let mut frame = Frame { + samples: [0.0; BLOCK], + len: BLOCK.min(2_646 - offset), + at: start + Duration::from_secs_f64(offset as f64 / 44_100.0), + generation: 0, + }; + for (index, sample) in frame.samples.iter_mut().enumerate() { + *sample = (std::f32::consts::TAU * 220.0 * (offset + index) as f32 / 44_100.0) + .sin() + * 0.1; + } + assert!(worker.buffers.rendered.push(frame).is_ok()); + } + enqueue(&worker, /*samples*/ 2_880, generation, now); + let before = receive_capture(&mut worker, &mut local, &mut received, now).await; + assert!(worker.buffers.rendered.is_empty()); + + // Start with a clean processor, then retain less than one Opus packet. + // If either resampler or partial encoder history survives the next mute, + // the equally short fresh segment below would emit audio too early. + worker.set_controls(muted()).unwrap(); + worker.set_controls(unmuted()).unwrap(); + let old_generation = worker.buffers.microphone.load(Ordering::Acquire); + now = Instant::now().max(start + Duration::from_millis(/*millis*/ 100)); + enqueue(&worker, /*samples*/ 720, old_generation, now); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0); + enqueue(&worker, /*samples*/ 2_880, old_generation, now); + worker.set_controls(muted()).unwrap(); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0); + let silence = received.recv().await.unwrap(); + let mut decoder = opus::Decoder::new(/*sample_rate*/ 48_000, opus::Channels::Mono).unwrap(); + let mut decoded = [1.0; 960]; + assert_eq!( + decoder + .decode_float(&silence.payload, &mut decoded, /*fec*/ false) + .unwrap(), + 960 + ); + assert!(decoded.iter().all(|sample| sample.abs() < 0.0001)); + assert_eq!( + silence.header.sequence_number, + before + .last() + .unwrap() + .header + .sequence_number + .wrapping_add(1) + ); + assert!(received.try_recv().is_err()); + + // A callback that began before mute may enqueue its old generation only + // after unmute; a newly started callback may still carry an old device + // timestamp. Exercise both independently through the actual service loop. + let pre_unmute = Instant::now() - Duration::from_millis(/*millis*/ 100); + worker.set_controls(unmuted()).unwrap(); + let generation = worker.buffers.microphone.load(Ordering::Acquire); + let resumed = Instant::now() + Duration::from_secs(/*secs*/ 1); + now = resumed; + enqueue(&worker, /*samples*/ 2_880, old_generation, now); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0); + enqueue(&worker, /*samples*/ 2_880, generation, pre_unmute); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0); + enqueue(&worker, /*samples*/ 720, generation, now); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0); + + now += Duration::from_millis(/*millis*/ 15); + enqueue(&worker, /*samples*/ 2_880, generation, now); + let after = receive_capture(&mut worker, &mut local, &mut received, now).await; + let clock_gap = after[0] + .header + .timestamp + .wrapping_sub(before[0].header.timestamp); + let expected_gap = (resumed.duration_since(start).as_secs_f64() * 48_000.0) as u32; + // Allow 1 ms for sample rounding without imposing a wall-clock throughput requirement. + assert!( + clock_gap.abs_diff(expected_gap) <= 48, + "received RTP clock gap: {clock_gap}, expected {expected_gap}" + ); + assert_eq!( + after[0].header.sequence_number, + silence.header.sequence_number.wrapping_add(1) + ); + assert!(received.try_recv().is_err()); + + // A mute received during a send must discard the rest of that encoded + // batch before the next service call, including after a quick unmute. + worker.set_controls(muted()).unwrap(); + worker.set_controls(unmuted()).unwrap(); + let generation = worker.buffers.microphone.load(Ordering::Acquire); + now = Instant::now() + Duration::from_secs(/*secs*/ 1); + enqueue(&worker, /*samples*/ 2_880, generation, now); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 1); + received.recv().await.unwrap(); + assert!(!worker.pending.is_empty()); + let pending = worker.pending.len(); + worker + .set_controls(AudioControls { + microphone_muted: false, + speaker_suppressed: true, + }) + .unwrap(); + assert_eq!(worker.pending.len(), pending); + worker.set_controls(muted()).unwrap(); + worker.set_controls(unmuted()).unwrap(); + assert!(worker.pending.is_empty()); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0); + assert!(received.try_recv().is_err()); + + // Waiting behind a slow send discards stale audio without ending the session. + let generation = worker.buffers.microphone.load(Ordering::Acquire); + now = Instant::now() + Duration::from_secs(/*secs*/ 1); + enqueue(&worker, /*samples*/ 2_880, generation, now); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 1); + received.recv().await.unwrap(); + let stale = now + Duration::from_secs(/*secs*/ 1); + assert_eq!(worker.service(&mut local.audio, || stale).await.unwrap(), 0); + assert!(received.try_recv().is_err()); + now = stale + Duration::from_millis(/*millis*/ 10); + enqueue(&worker, /*samples*/ 2_880, generation, now); + let resumed = receive_capture(&mut worker, &mut local, &mut received, now).await; + assert!(!resumed.is_empty()); + + // A saturated callback queue also retires partial processing state, then + // admits new capture once the worker catches up. + enqueue(&worker, /*samples*/ 2_880, generation, now); + worker + .buffers + .capture_dropped + .store(true, Ordering::Release); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0); + assert!(received.try_recv().is_err()); + now += Duration::from_millis(/*millis*/ 70); + enqueue(&worker, /*samples*/ 2_880, generation, now); + assert!( + !receive_capture(&mut worker, &mut local, &mut received, now) + .await + .is_empty() + ); + + // A delayed worker must discard a full render-reference queue before + // it feeds stale speaker audio back into echo cancellation. + let mut output = [0.0_f32; BLOCK]; + let mut output_state = super::super::OutputState::default(); + for _ in 0..=worker.buffers.rendered.capacity() { + super::super::render_output( + &mut output, + /*channels*/ 1, + /*rate*/ 44_100.0, + now, + &worker.buffers, + &mut output_state, + ); + } + assert_eq!( + worker.buffers.rendered.len(), + worker.buffers.rendered.capacity() + ); + assert!(worker.buffers.render_dropped.load(Ordering::Acquire)); + assert_eq!(worker.service(&mut local.audio, || now).await.unwrap(), 0); + assert!(worker.buffers.rendered.is_empty()); + assert!(!worker.buffers.render_dropped.load(Ordering::Acquire)); + assert!(!worker.buffers.failed.load(Ordering::Acquire)); + + now += Duration::from_millis(/*millis*/ 70); + super::super::render_output( + &mut output, + /*channels*/ 1, + /*rate*/ 44_100.0, + now, + &worker.buffers, + &mut output_state, + ); + enqueue(&worker, /*samples*/ 2_880, generation, now); + assert!( + !receive_capture(&mut worker, &mut local, &mut received, now) + .await + .is_empty() + ); + assert!(worker.buffers.rendered.is_empty()); + local.close().await.unwrap(); + remote.close().await.unwrap(); + }) + .await + .unwrap(); +} diff --git a/codex-rs/voice-host/src/device_buffers.rs b/codex-rs/voice-host/src/device_buffers.rs new file mode 100644 index 0000000000000000000000000000000000000000..b38f13fe2e70b29414944c2bea4aafad217bcc50 --- /dev/null +++ b/codex-rs/voice-host/src/device_buffers.rs @@ -0,0 +1,276 @@ +//! Meter reads clear accumulated peaks and surface callback failures. +//! Preallocated callback buffers pack small callbacks into full queue slots without locks. +//! Generation changes, rejected capture buffers and capture gaps discard incomplete frames. +//! Capture/render capacity covers the worker deadline; playback and callback limits stay fixed. + +use std::sync::Mutex; +use std::sync::PoisonError; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU16; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; + +use crossbeam_queue::ArrayQueue; + +pub(super) const BLOCK: usize = 256; +pub(super) const QUEUE_CAPACITY: usize = 32; +pub(super) const MAX_CALLBACK_FRAMES: usize = BLOCK * QUEUE_CAPACITY; +// Allow ordinary timestamp jitter consistently at both capture buffering boundaries. +pub(super) const CAPTURE_GAP_TOLERANCE: Duration = Duration::from_millis(/*millis*/ 20); + +pub(super) struct Frame { + pub(super) samples: [f32; BLOCK], + pub(super) len: usize, + pub(super) at: Instant, + pub(super) generation: u64, +} + +/// Packs serialized callbacks without allocating; only complete blocks enter the queue. +/// The oldest sample keeps its timestamp. Callers reset after rejected capture data. +#[derive(Default)] +pub(super) struct FramePacker { + frame: Option, +} + +impl FramePacker { + pub(super) fn reset(&mut self) { + self.frame = None; + } + + // Capture checks continuity before packing can hide a gap inside an old partial block. + // Render callbacks retain their separate existing packing behavior. + pub(super) fn discard_capture_gap(&mut self, start: Instant, rate: f64) { + if self.frame.as_ref().is_some_and(|partial| { + let end = partial.at + Duration::from_secs_f64(partial.len as f64 / rate); + start.saturating_duration_since(end) > CAPTURE_GAP_TOLERANCE + }) { + self.reset(); + } + } + + pub(super) fn push(&mut self, frame: Frame, rate: f64, queue: &ArrayQueue) -> bool { + if self + .frame + .as_ref() + .is_some_and(|partial| partial.generation != frame.generation) + { + self.reset(); + } + let mut offset = 0; + while offset < frame.len { + let partial = self.frame.get_or_insert_with(|| Frame { + samples: [0.0; BLOCK], + len: 0, + at: frame.at + Duration::from_secs_f64(offset as f64 / rate), + generation: frame.generation, + }); + let count = (BLOCK - partial.len).min(frame.len - offset); + partial.samples[partial.len..partial.len + count] + .copy_from_slice(&frame.samples[offset..offset + count]); + partial.len += count; + offset += count; + if partial.len == BLOCK + && let Some(full) = self.frame.take() + && let Err(rejected) = queue.push(full) + { + // Suppressed output is known silence; dropping it cannot lose audible output. + // Never discard an active generation or a block containing real audio. + if rejected.generation.is_multiple_of(2) + || rejected.samples.iter().any(|sample| *sample != 0.0) + { + return false; + } + } + } + true + } +} + +pub(super) struct Buffers { + pub(super) capture: ArrayQueue, + pub(super) rendered: ArrayQueue, + pub(super) playback: ArrayQueue, + pub(super) microphone: AtomicU64, + pub(super) speaker: AtomicU64, + pub(super) speaker_failure_gate: Mutex<()>, + pub(super) serviced: AtomicBool, + pub(super) failed: AtomicBool, + pub(super) capture_dropped: AtomicBool, + pub(super) render_dropped: AtomicBool, + pub(super) microphone_peak: AtomicU16, + pub(super) speaker_peak: AtomicU16, + pub(super) queued: AtomicU32, + pub(super) last_dac_ns: AtomicU64, + pub(super) clock: Instant, + pub(super) callback_sequence: AtomicU64, +} + +impl Buffers { + pub(super) fn take_state(&self) -> std::io::Result { + if self.failed.load(Ordering::Acquire) { + return Err(std::io::Error::other("audio device failed")); + } + Ok(codex_realtime_webrtc::AudioState { + microphone_peak: self.microphone_peak.swap(/*val*/ 0, Ordering::AcqRel), + speaker_peak: self.speaker_peak.swap(/*val*/ 0, Ordering::AcqRel), + }) + } + + pub(super) fn new(input_rate: u32, output_rate: u32) -> Self { + // Rates are validated before opening devices. A pending batch can defer + // capture consumption until its freshness deadline plus one in-flight send. + let budget = super::processing::MAX_PROCESSING_DELAY + + crate::audio_track::SEND_TIMEOUT + + 2 * crate::DEVICE_SERVICE_INTERVAL; + let capacity = |rate: u32| { + let samples = (u128::from(rate) * budget.as_nanos()).div_ceil(1_000_000_000); + (samples as usize + MAX_CALLBACK_FRAMES + BLOCK).div_ceil(BLOCK) + }; + Self { + capture: ArrayQueue::new(capacity(input_rate)), + rendered: ArrayQueue::new(capacity(output_rate)), + playback: ArrayQueue::new(QUEUE_CAPACITY), + microphone: AtomicU64::new(/*v*/ 1), + speaker: AtomicU64::new(/*v*/ 1), + speaker_failure_gate: Mutex::new(()), + serviced: AtomicBool::new(false), + failed: AtomicBool::new(false), + capture_dropped: AtomicBool::new(false), + render_dropped: AtomicBool::new(false), + microphone_peak: AtomicU16::new(/*v*/ 0), + speaker_peak: AtomicU16::new(/*v*/ 0), + queued: AtomicU32::new(/*v*/ 0), + last_dac_ns: AtomicU64::new(/*v*/ 0), + clock: Instant::now(), + callback_sequence: AtomicU64::new(/*v*/ 0), + } + } + + pub(super) fn push_playback(&self, frame: Frame) -> Result<(), ()> { + let len = frame.len as u32; + self.queued.fetch_add(len, Ordering::AcqRel); + self.playback.push(frame).map_err(|_| { + self.queued.fetch_sub(len, Ordering::AcqRel); + }) + } + + pub(super) fn set_speaker_disabled(&self, disabled: bool) -> std::io::Result<()> { + let _transition = self + .speaker_failure_gate + .lock() + .unwrap_or_else(PoisonError::into_inner); + Self::set_disabled(&self.speaker, disabled) + } + + // One control worker writes each epoch. Odd epochs are disabled; every + // transition advances the epoch so disable/re-enable cannot replay old audio. + pub(super) fn set_disabled(epoch: &AtomicU64, disabled: bool) -> std::io::Result<()> { + let current = epoch.load(Ordering::Acquire); + if (current % 2 == 1) != disabled { + epoch.store( + current + .checked_add(1) + .ok_or_else(|| std::io::Error::other("audio generation exhausted"))?, + Ordering::Release, + ); + } + Ok(()) + } +} + +/// Rejects capture backlog after unmute, using offsets in the device's clock. +/// This is conservative software admission, not a guarantee of hardware clock accuracy. +#[derive(Default)] +pub(super) struct CaptureBoundary { + generation: Option, + cutoff: Option, + previous_capture: Option, +} + +impl CaptureBoundary { + pub(super) fn accepts( + &mut self, + generation: u64, + callback: Duration, + capture: Duration, + ) -> bool { + if generation % 2 == 1 || self.generation != Some(generation) { + self.generation = Some(generation); + self.cutoff = None; + self.previous_capture = None; + // The current callback timestamp may have been sampled before unmute. + // Wait for the next serialized callback before establishing a cutoff. + return false; + } + let accepted = capture >= *self.cutoff.get_or_insert(callback) + && self + .previous_capture + .is_none_or(|previous| capture >= previous); + if accepted { + self.previous_capture = Some(capture); + } + accepted + } +} + +#[derive(Default)] +pub(super) struct Playback { + frame: Option, + offset: usize, +} + +impl Playback { + pub(super) fn next(&mut self, buffers: &Buffers) -> Option { + let epoch = buffers.speaker.load(Ordering::Acquire); + if self + .frame + .as_ref() + .is_some_and(|frame| frame.generation != epoch || self.offset == frame.len) + { + if let Some(frame) = &self.frame { + buffers.queued.fetch_sub( + frame.len.saturating_sub(self.offset) as u32, + Ordering::AcqRel, + ); + } + self.frame = None; + } + // Bound stale-frame work even if a producer keeps writing during a mute. + for _ in 0..buffers.playback.capacity() { + if self.frame.is_some() { + break; + } + let Some(frame) = buffers.playback.pop() else { + break; + }; + if epoch.is_multiple_of(2) + && frame.generation == epoch + && frame.len > 0 + && frame.len <= BLOCK + { + self.frame = Some(frame); + self.offset = 0; + } else { + buffers.queued.fetch_sub(frame.len as u32, Ordering::AcqRel); + } + } + let Some(frame) = &self.frame else { + return None; + }; + let sample = frame.samples[self.offset]; + self.offset += 1; + buffers.queued.fetch_sub(/*val*/ 1, Ordering::AcqRel); + if epoch % 2 == 1 || !sample.is_finite() { + Some(0.0) + } else { + Some(sample.clamp(-1.0, 1.0)) + } + } +} + +#[cfg(test)] +#[path = "device_buffers_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/device_buffers_tests.rs b/codex-rs/voice-host/src/device_buffers_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..ab6b295d0a4eb05d20aeed8ec6ff1112d637bf84 --- /dev/null +++ b/codex-rs/voice-host/src/device_buffers_tests.rs @@ -0,0 +1,252 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn unmute_rejects_backlog_until_capture_reaches_the_following_callback() { + let mut boundary = CaptureBoundary::default(); + // Times are offsets in the same device clock. The first callback's timestamp + // can precede unmute, so even capture after that timestamp must be rejected. + for (generation, callback, capture, expected) in [ + (1, 20, 10, false), // muted + (2, 21, 15, false), // first observed unmute + (2, 25, 22, false), // next callback establishes cutoff 25 + (2, 26, 24, false), // another old/crossing buffer + (2, 27, 25, true), + (2, 28, 26, true), // same generation must not move the cutoff + (2, 29, 25, false), // backwards device capture is a discontinuity + (2, 30, 26, true), // forward capture can resume without moving the cutoff + (4, 30, 29, false), // mute/unmute happened without a callback + (4, 34, 31, false), + (4, 35, 33, false), + (4, 36, 34, true), + (5, 37, 35, false), // observed mute + (6, 38, 36, false), + (6, 40, 40, true), + (6, 101, 100, true), + (6, 102, 90, false), + (6, 103, 95, false), // rejection must not lower the last accepted timestamp + (6, 104, 101, true), + ] { + assert_eq!( + boundary.accepts( + generation, + Duration::from_millis(callback), + Duration::from_millis(capture), + ), + expected, + "generation={generation}, callback={callback}, capture={capture}", + ); + } +} + +#[test] +fn suppression_discards_partial_and_queued_previous_generations() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000); + let mut playback = Playback::default(); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + let generation = buffers.speaker.load(Ordering::Acquire); + for _ in 0..2 { + assert!( + buffers + .push_playback(Frame { + samples: [0.5; BLOCK], + len: BLOCK, + at: Instant::now(), + generation + }) + .is_ok() + ); + } + assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.5); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap(); + assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.0); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + assert_eq!(playback.next(&buffers).unwrap_or(0.0), 0.0); + assert!(buffers.playback.is_empty()); +} + +#[test] +fn output_is_finite_and_bounded_and_underflow_is_silence() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + let generation = buffers.speaker.load(Ordering::Acquire); + let mut samples = [0.0; BLOCK]; + samples[..4].copy_from_slice(&[f32::NAN, f32::INFINITY, -2.0, 2.0]); + assert!( + buffers + .push_playback(Frame { + samples, + len: 4, + at: Instant::now(), + generation + }) + .is_ok() + ); + let mut playback = Playback::default(); + let output: Vec<_> = (0..5) + .map(|_| playback.next(&buffers).unwrap_or(0.0)) + .collect(); + assert_eq!(output, [0.0, 0.0, -1.0, 1.0, 0.0]); +} + +#[test] +fn repeated_controls_preserve_epoch_but_transitions_invalidate_capture() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000); + Buffers::set_disabled(&buffers.microphone, /*disabled*/ false).unwrap(); + let first = buffers.microphone.load(Ordering::Acquire); + Buffers::set_disabled(&buffers.microphone, /*disabled*/ false).unwrap(); + assert_eq!(buffers.microphone.load(Ordering::Acquire), first); + Buffers::set_disabled(&buffers.microphone, /*disabled*/ true).unwrap(); + Buffers::set_disabled(&buffers.microphone, /*disabled*/ false).unwrap(); + assert_ne!(buffers.microphone.load(Ordering::Acquire), first); +} + +#[test] +fn tiny_callbacks_share_slots_and_preserve_the_oldest_timestamp() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000); + let start = Instant::now(); + for queue in [&buffers.capture, &buffers.rendered] { + let mut packer = FramePacker::default(); + // Five milliseconds at 384 kHz arrives as 120 sixteen-frame callbacks. + for callback in 0..120 { + assert!(packer.push( + Frame { + samples: [callback as f32; BLOCK], + len: 16, + at: start + Duration::from_secs_f64((callback * 16) as f64 / 384_000.0), + generation: 2, + }, + /*rate*/ 384_000.0, + queue, + )); + } + assert_eq!(queue.len(), 7); + for block in 0..7 { + let frame = queue.pop().unwrap(); + assert_eq!( + (frame.samples, frame.len, frame.at, frame.generation), + ( + std::array::from_fn(|sample| (block * 16 + sample / 16) as f32), + BLOCK, + start + Duration::from_secs_f64((block * BLOCK) as f64 / 384_000.0), + 2, + ), + ); + } + } +} + +#[test] +fn packing_drops_partial_audio_on_generation_changes_and_capture_rejection() { + let queue = ArrayQueue::new(QUEUE_CAPACITY); + let mut packer = FramePacker::default(); + let start = Instant::now(); + for (generation, sample) in [(2, 0.1), (4, 0.2), (4, 0.3)] { + assert!(packer.push( + Frame { + samples: [sample; BLOCK], + len: BLOCK / 2, + at: start, + generation, + }, + /*rate*/ 48_000.0, + &queue, + )); + } + let frame = queue.pop().unwrap(); + assert_eq!( + (frame.samples, frame.len, frame.at, frame.generation), + ( + std::array::from_fn(|i| if i < BLOCK / 2 { 0.2 } else { 0.3 }), + BLOCK, + start, + 4 + ), + ); + for sample in [0.4, 0.5] { + assert!(packer.push( + Frame { + samples: [sample; BLOCK], + len: BLOCK / 2, + at: start, + generation: 4 + }, + /*rate*/ 48_000.0, + &queue, + )); + packer.reset(); + } + assert!(queue.is_empty()); +} + +#[test] +fn packing_preserves_a_remainder_timestamp_across_uneven_callbacks() { + let queue = ArrayQueue::new(QUEUE_CAPACITY); + let mut packer = FramePacker::default(); + let start = Instant::now(); + let second = start + Duration::from_millis(10); + for (len, sample, at) in [(100, 0.1, start), (200, 0.2, second), (212, 0.3, second)] { + assert!(packer.push( + Frame { + samples: [sample; BLOCK], + len, + at, + generation: 2 + }, + /*rate*/ 48_000.0, + &queue, + )); + } + let first = queue.pop().unwrap(); + let remainder = queue.pop().unwrap(); + assert_eq!( + (first.samples, first.at, remainder.samples, remainder.at), + ( + std::array::from_fn(|i| if i < 100 { 0.1 } else { 0.2 }), + start, + std::array::from_fn(|i| if i < 44 { 0.2 } else { 0.3 }), + second + Duration::from_secs_f64(156.0 / 48_000.0), + ), + ); + assert!(queue.is_empty()); +} + +#[test] +fn capture_queue_retains_high_rate_audio_during_bounded_service_pause() { + let buffers = Buffers::new(/*input_rate*/ 384_000, /*output_rate*/ 48_000); + let mut packer = FramePacker::default(); + let start = Instant::now(); + // 630 ms covers a pending batch's 500 ms deadline, an in-flight 100 ms + // send and callback/service margins. Consumption is deliberately paused. + let samples = 384_000 * 63 / 100; + for offset in (0..samples).step_by(17) { + assert!(packer.push( + Frame { + samples: [0.25; BLOCK], + len: (samples - offset).min(17), + at: start + Duration::from_secs_f64(offset as f64 / 384_000.0), + generation: 2, + }, + /*rate*/ 384_000.0, + &buffers.capture, + )); + } + assert_eq!(buffers.capture.len(), samples / BLOCK); + // Enlarging the queue must not turn overflow into unbounded accumulation. + for _ in buffers.capture.len()..=buffers.capture.capacity() { + let available = buffers.capture.len() < buffers.capture.capacity(); + assert_eq!( + packer.push( + Frame { + samples: [0.25; BLOCK], + len: BLOCK, + at: start, + generation: 2, + }, + /*rate*/ 384_000.0, + &buffers.capture, + ), + available, + ); + } +} diff --git a/codex-rs/voice-host/src/devices.rs b/codex-rs/voice-host/src/devices.rs new file mode 100644 index 0000000000000000000000000000000000000000..93f43fe94f303e8005166ced66670d843835a942 --- /dev/null +++ b/codex-rs/voice-host/src/devices.rs @@ -0,0 +1,469 @@ +//! Owns local device streams on the helper worker. Callbacks allocate no buffers and take no locks. +//! Small callbacks share full queue slots; overload discards stale media and resumes fresh audio. +//! Capture and actual rendered output carry device timing. +//! References start with worker service; unmute rejects earlier device capture buffers. + +#[path = "audio_sink.rs"] +mod audio_sink; +#[path = "device_buffers.rs"] +mod buffers; +#[path = "capture_worker.rs"] +mod capture_worker; +#[path = "playback.rs"] +mod playback; +#[path = "playout.rs"] +mod playout; +#[path = "processing.rs"] +mod processing; + +use std::io; +use std::sync::Arc; +use std::sync::atomic::AtomicU16; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; + +use cpal::FromSample; +use cpal::Sample; +use cpal::SampleFormat; +use cpal::SizedSample; +use cpal::traits::DeviceTrait; +use cpal::traits::HostTrait; +use cpal::traits::StreamTrait; + +use buffers::BLOCK; +use buffers::Buffers; +use buffers::CaptureBoundary; +use buffers::Frame; +use buffers::FramePacker; +use buffers::MAX_CALLBACK_FRAMES; +use buffers::Playback; +use buffers::QUEUE_CAPACITY; +use playback::PlaybackPort; + +const MAX_CAPTURE_AGE: Duration = Duration::from_secs(/*secs*/ 1); + +pub(super) struct Devices { + _input: cpal::Stream, + output: Option, + output_device: cpal::Device, + input_frames: u32, + playback: PlaybackPort, + playout: Option, + worker: capture_worker::CaptureWorker, +} + +macro_rules! stream { + ($format:expr, $build:ident, $($arg:expr),+) => { + match $format { + SampleFormat::I8 => $build::($($arg),+), + SampleFormat::I16 => $build::($($arg),+), + SampleFormat::I32 => $build::($($arg),+), + SampleFormat::I64 => $build::($($arg),+), + SampleFormat::U8 => $build::($($arg),+), + SampleFormat::U16 => $build::($($arg),+), + SampleFormat::U32 => $build::($($arg),+), + SampleFormat::U64 => $build::($($arg),+), + SampleFormat::F32 => $build::($($arg),+), + SampleFormat::F64 => $build::($($arg),+), + _ => Err(cpal::Error::from(cpal::ErrorKind::UnsupportedConfig)), + } + }; +} + +impl Devices { + pub(super) fn take_state(&self) -> io::Result { + self.worker.buffers.take_state() + } + + pub(super) fn open() -> io::Result { + let host = cpal::default_host(); + let input = host + .default_input_device() + .ok_or_else(|| io::Error::other("microphone unavailable"))?; + let output = host + .default_output_device() + .ok_or_else(|| io::Error::other("speaker unavailable"))?; + let input_config = input + .default_input_config() + .map_err(|_| io::Error::other("microphone configuration unavailable"))?; + let output_config = output + .default_output_config() + .map_err(|_| io::Error::other("speaker configuration unavailable"))?; + let input_stream_config = bounded_stream_config(&input_config)?; + let output_stream_config = bounded_stream_config(&output_config)?; + let buffers = Arc::new(Buffers::new( + input_config.sample_rate(), + output_config.sample_rate(), + )); + let playback = PlaybackPort::new(buffers.clone(), output_config.sample_rate()); + let processor = + processing::Processor::new(input_config.sample_rate(), output_config.sample_rate()) + .map_err(io::Error::other)?; + let (cpal::BufferSize::Fixed(input_frames), cpal::BufferSize::Fixed(output_frames)) = ( + input_stream_config.buffer_size, + output_stream_config.buffer_size, + ) else { + return Err(io::Error::other("audio callback size unavailable")); + }; + processor + .validate_callback_timing(input_frames, output_frames) + .map_err(io::Error::other)?; + let input = stream!( + input_config.sample_format(), + build_input, + &input, + &input_stream_config, + buffers.clone() + ) + .map_err(|_| io::Error::other("failed to open microphone"))?; + let output_stream = stream!( + output_config.sample_format(), + build_output, + &output, + &output_stream_config, + buffers.clone() + ) + .map_err(|_| io::Error::other("failed to open speaker"))?; + output_stream + .play() + .map_err(|_| io::Error::other("failed to start speaker"))?; + input + .play() + .map_err(|_| io::Error::other("failed to start microphone"))?; + Ok(Self { + _input: input, + output: Some(output_stream), + output_device: output, + input_frames, + playback, + playout: None, + worker: capture_worker::CaptureWorker { + buffers, + processor, + pending: Default::default(), + }, + }) + } + + pub(super) fn set_controls( + &mut self, + controls: codex_realtime_webrtc::AudioControls, + ) -> io::Result<()> { + let buffers = self.worker.buffers.clone(); + let previous = buffers.speaker.load(Ordering::Acquire); + self.worker.set_controls(controls)?; + if previous != buffers.speaker.load(Ordering::Acquire) { + // Stop callbacks before native teardown can block the worker. + drop(self.output.take()); + drop(self.playout.take()); + // The new epoch cancelled writers before either teardown; wait for + // any in-flight write before resetting its queue. + let producer = self + .playback + .0 + .producer + .lock() + .map_err(|_| io::Error::other("speaker writer failed"))?; + while buffers.playback.pop().is_some() {} + while buffers.rendered.pop().is_some() {} + buffers.queued.store(/*val*/ 0, Ordering::Release); + buffers.last_dac_ns.store(/*val*/ 0, Ordering::Release); + drop(producer); + while buffers.rendered.pop().is_some() {} + // Opening a Bluetooth microphone can change the speaker's format. + // Requery after stopping the old stream rather than restoring its + // pre-microphone rate, which the device may no longer support. + let output_config = self + .output_device + .default_output_config() + .map_err(|_| io::Error::other("speaker configuration unavailable"))?; + let output_stream_config = bounded_stream_config(&output_config)?; + let cpal::BufferSize::Fixed(output_frames) = output_stream_config.buffer_size else { + return Err(io::Error::other("audio callback size unavailable")); + }; + self.worker + .processor + .set_render_rate(output_config.sample_rate()) + .map_err(io::Error::other)?; + self.worker + .processor + .validate_callback_timing(self.input_frames, output_frames) + .map_err(io::Error::other)?; + self.playback = PlaybackPort::new(buffers.clone(), output_config.sample_rate()); + if !controls.speaker_suppressed { + self.playout = + Some(playout::Playout::new(self.playback.writer()).map_err(io::Error::other)?); + } + let output = stream!( + output_config.sample_format(), + build_output, + &self.output_device, + &output_stream_config, + buffers + ) + .map_err(|_| io::Error::other("failed to reset speaker"))?; + output + .play() + .map_err(|_| io::Error::other("failed to restart speaker"))?; + self.output = Some(output); + } + Ok(()) + } + + pub(super) async fn service( + &mut self, + audio: &mut crate::audio_track::AudioTrack, + ) -> io::Result { + if let Some(playout) = &self.playout { + playout + .check() + .map_err(|_| io::Error::other(crate::service_failure::ServiceFailure::Playout))?; + } + self.worker.service(audio, Instant::now).await + } + + pub(super) fn receive(&self, packet: crate::incoming::ReceivedRtp) -> io::Result<()> { + if let Some(playout) = &self.playout { + playout.push(packet).map_err(io::Error::other)?; + } + Ok(()) + } +} + +impl Drop for Devices { + fn drop(&mut self) { + self.worker.buffers.failed.store(true, Ordering::Release); + drop(self.output.take()); + drop(self.playout.take()); + let _producer = self.playback.0.producer.lock().ok(); + } +} + +fn bounded_stream_config( + supported: &cpal::SupportedStreamConfig, +) -> io::Result { + if supported.channels() == 0 + || supported.channels() > 32 + || !(8_000..=384_000).contains(&supported.sample_rate()) + { + return Err(io::Error::other("unsupported audio device configuration")); + } + let cpal::SupportedBufferSize::Range { min, max } = *supported.buffer_size() else { + return Err(io::Error::other("audio callback size range unavailable")); + }; + let mut config = supported.config(); + let min = min.max(1); + let max = max.min(MAX_CALLBACK_FRAMES as u32); + if min > max { + return Err(io::Error::other("unsupported audio callback size range")); + } + // ALSA allocates two periods. A 20 ms ring can be smaller than one PipeWire + // graph cycle (e.g. 2048 frames at 48 kHz), silently losing capture samples + // every cycle. Give Linux a bounded 100 ms ring instead, while retaining + // 10 ms callbacks elsewhere and rejecting incompatible device ranges below. + let periods_per_second = if cfg!(target_os = "linux") { 20 } else { 100 }; + let frames = (config.sample_rate / periods_per_second).clamp(min, max); + let callback_duration = + Duration::from_secs_f64(f64::from(frames) / f64::from(config.sample_rate)); + // Backends may deliver smaller callbacks than requested. Packing makes queue + // coverage depend on samples, while an incomplete block adds bounded latency. + let packing_duration = Duration::from_secs_f64(BLOCK as f64 / f64::from(config.sample_rate)); + let queue_duration = packing_duration * QUEUE_CAPACITY as u32; + if callback_duration + packing_duration + super::DEVICE_SERVICE_INTERVAL >= MAX_CAPTURE_AGE + || queue_duration <= super::DEVICE_SERVICE_INTERVAL + { + return Err(io::Error::other("unsupported audio callback timing")); + } + config.buffer_size = cpal::BufferSize::Fixed(frames); + Ok(config) +} + +fn record_peak(peak: &AtomicU16, sample: f32) { + peak.fetch_max( + (sample.abs().min(1.0) * f32::from(u16::MAX)) as u16, + Ordering::Relaxed, + ); +} + +// CPAL reports recoverable underruns/overruns through the same callback as device loss. +fn handle_stream_error(buffers: &Buffers, error: cpal::Error) { + if error.kind() != cpal::ErrorKind::Xrun { + buffers.failed.store(true, Ordering::Release); + } +} + +fn build_input( + device: &cpal::Device, + config: &cpal::StreamConfig, + buffers: Arc, +) -> Result +where + T: SizedSample, + f32: FromSample, +{ + let channels = usize::from(config.channels); + let rate = f64::from(config.sample_rate); + let failure = buffers.clone(); + let mut origin = None; + let mut boundary = CaptureBoundary::default(); + let mut capture = FramePacker::default(); + device.build_input_stream( + *config, + move |data: &[T], info: &cpal::InputCallbackInfo| { + let generation = buffers.microphone.load(Ordering::Acquire); + if generation % 2 == 1 { + capture.reset(); + return; + } + let timestamp = info.timestamp(); + let origin = origin.get_or_insert(timestamp.capture); + let (Some(callback), Some(captured)) = ( + timestamp.callback.checked_duration_since(*origin), + timestamp.capture.checked_duration_since(*origin), + ) else { + capture.reset(); + return; + }; + if !boundary.accepts(generation, callback, captured) { + capture.reset(); + return; + } + let start = Instant::now() + .checked_sub( + timestamp + .callback + .checked_duration_since(timestamp.capture) + .unwrap_or_default(), + ) + .unwrap_or_else(Instant::now); + capture.discard_capture_gap(start, rate); + for (index, chunk) in data.chunks(BLOCK * channels).enumerate() { + let mut frame = Frame { + samples: [0.0; BLOCK], + len: chunk.len() / channels, + at: start + Duration::from_secs_f64((index * BLOCK) as f64 / rate), + generation, + }; + for (output, input) in frame.samples.iter_mut().zip(chunk.chunks_exact(channels)) { + *output = input + .iter() + .map(|sample| f32::from_sample(*sample)) + .sum::() + / channels as f32; + if !output.is_finite() { + buffers.failed.store(true, Ordering::Release); + return; + } + record_peak(&buffers.microphone_peak, *output); + } + if !capture.push(frame, rate, &buffers.capture) { + capture.reset(); + buffers.capture_dropped.store(true, Ordering::Release); + return; + } + } + }, + move |error| handle_stream_error(&failure, error), + /*timeout*/ None, + ) +} + +fn build_output( + device: &cpal::Device, + config: &cpal::StreamConfig, + buffers: Arc, +) -> Result +where + T: SizedSample + FromSample, + f32: FromSample, +{ + let channels = usize::from(config.channels); + let rate = f64::from(config.sample_rate); + let failure = buffers.clone(); + let mut output = OutputState::default(); + device.build_output_stream( + *config, + move |data: &mut [T], info: &cpal::OutputCallbackInfo| { + buffers + .callback_sequence + .fetch_add(/*val*/ 1, Ordering::AcqRel); + let timestamp = info.timestamp(); + let start = Instant::now() + + timestamp + .playback + .checked_duration_since(timestamp.callback) + .unwrap_or_default(); + render_output(data, channels, rate, start, &buffers, &mut output); + buffers + .callback_sequence + .fetch_add(/*val*/ 1, Ordering::Release); + }, + move |error| handle_stream_error(&failure, error), + /*timeout*/ None, + ) +} + +#[derive(Default)] +struct OutputState { + playback: Playback, + reference: FramePacker, +} + +fn render_output( + data: &mut [T], + channels: usize, + rate: f64, + start: Instant, + buffers: &Buffers, + output: &mut OutputState, +) where + T: SizedSample + FromSample, + f32: FromSample, +{ + if !buffers.serviced.load(Ordering::Acquire) { + data.fill(T::from_sample(0.0)); + return; + } + let mut delivered_until = None; + for (index, chunk) in data.chunks_mut(BLOCK * channels).enumerate() { + let mut reference = Frame { + samples: [0.0; BLOCK], + len: chunk.len() / channels, + at: start + Duration::from_secs_f64((index * BLOCK) as f64 / rate), + generation: buffers.speaker.load(Ordering::Acquire), + }; + for (offset, (frame, sample)) in chunk + .chunks_mut(channels) + .zip(&mut reference.samples) + .enumerate() + { + let next = output.playback.next(buffers); + if next.is_some() { + delivered_until = Some( + start + Duration::from_secs_f64((index * BLOCK + offset + 1) as f64 / rate), + ); + } + let rendered = T::from_sample(next.unwrap_or(0.0)); + frame.fill(rendered); + *sample = f32::from_sample(rendered); + record_peak(&buffers.speaker_peak, *sample); + } + if !output.reference.push(reference, rate, &buffers.rendered) { + output.reference.reset(); + buffers.render_dropped.store(true, Ordering::Release); + } + } + if let Some(end) = delivered_until { + buffers.last_dac_ns.store( + end.saturating_duration_since(buffers.clock) + .as_nanos() + .min(u128::from(u64::MAX)) as u64, + Ordering::Release, + ); + } +} + +#[cfg(test)] +#[path = "devices_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/devices_tests.rs b/codex-rs/voice-host/src/devices_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..e9f7b40dab412b8cd001c1d2b6ac7029aa1143c3 --- /dev/null +++ b/codex-rs/voice-host/src/devices_tests.rs @@ -0,0 +1,425 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn startup_output_waits_for_service_and_later_overflow_discards_reference() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000); + let mut state = OutputState::default(); + let mut output = [1.0_f32; BLOCK * 2]; + let start = Instant::now(); + for _ in 0..QUEUE_CAPACITY + 1 { + render_output( + &mut output, + /*channels*/ 2, + /*rate*/ 384_000.0, + start, + &buffers, + &mut state, + ); + assert_eq!(output, [0.0; BLOCK * 2]); + } + assert!(buffers.rendered.is_empty()); + assert!(!buffers.failed.load(Ordering::Acquire)); + + buffers.serviced.store(true, Ordering::Release); + render_output( + &mut output, + /*channels*/ 2, + /*rate*/ 384_000.0, + start, + &buffers, + &mut state, + ); + let reference = buffers.rendered.pop().unwrap(); + assert_eq!( + ( + reference.samples, + reference.len, + reference.at, + reference.generation + ), + ( + [0.0; BLOCK], + BLOCK, + start, + buffers.speaker.load(Ordering::Acquire) + ), + ); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + for _ in 0..buffers.rendered.capacity() { + render_output( + &mut output, + /*channels*/ 2, + /*rate*/ 384_000.0, + start, + &buffers, + &mut state, + ); + } + assert!(!buffers.failed.load(Ordering::Acquire)); + render_output( + &mut output, + /*channels*/ 2, + /*rate*/ 384_000.0, + start, + &buffers, + &mut state, + ); + assert!(!buffers.failed.load(Ordering::Acquire)); + assert!(buffers.render_dropped.load(Ordering::Acquire)); + assert!(buffers.take_state().is_ok()); +} + +#[test] +fn callback_configuration_fits_supported_range_and_actual_queue() { + for (rate, min, max, other_frames, linux_frames) in [ + (96_000, 0, u32::MAX, 960, 4_800), + (96_000, 1, 16, 16, 16), + (384_000, 1, 64, 64, 64), + (8_000, 7_680, 8_192, 7_680, 7_680), + (384_000, 1, 16, 16, 16), + (96_000, 1, 15, 15, 15), + (48_000, 1, 128, 128, 128), + (48_000, 2_048, 16_384, 2_048, 2_400), + (384_000, 6_016, 16_384, 6_016, 8_192), + ] { + let frames = if cfg!(target_os = "linux") { + linux_frames + } else { + other_frames + }; + let supported = cpal::SupportedStreamConfig::new( + /*channels*/ 2, + rate, + cpal::SupportedBufferSize::Range { min, max }, + cpal::SampleFormat::F32, + ); + let config = bounded_stream_config(&supported).unwrap(); + assert_eq!( + config, + cpal::StreamConfig { + channels: 2, + sample_rate: rate, + buffer_size: cpal::BufferSize::Fixed(frames), + } + ); + let buffers = Buffers::new(rate, rate); + for queue in [&buffers.capture, &buffers.rendered] { + for offset in (0..frames as usize).step_by(BLOCK) { + assert!( + queue + .push(Frame { + samples: [0.0; BLOCK], + len: (frames as usize - offset).min(BLOCK), + at: Instant::now(), + generation: 0, + }) + .is_ok() + ); + } + } + } +} + +#[test] +#[cfg(target_os = "linux")] +fn pipewire_graph_cycles_support_capture_and_playback() { + let supported = cpal::SupportedStreamConfig::new( + /*channels*/ 2, + /*sample_rate*/ 48_000, + cpal::SupportedBufferSize::Range { + min: 1, + max: 16_384, + }, + cpal::SampleFormat::F32, + ); + let cpal::BufferSize::Fixed(period) = bounded_stream_config(&supported).unwrap().buffer_size + else { + panic!("callback size must be bounded"); + }; + let mut processor = + processing::Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + let mut packer = FramePacker::default(); + let buffers = Arc::new(Buffers::new( + /*input_rate*/ 48_000, /*output_rate*/ 48_000, + )); + let start = Instant::now(); + let mut available = 0; + let mut packets = Vec::new(); + // Model measured 2048-frame graph cycles and ALSA's two-period ring. + // Packing and encoding must sustain 20 ms packets despite this cadence. + for cycle in 1..=48 { + available = (available + 2_048).min(2 * period); + let now = start + Duration::from_secs_f64(f64::from(cycle * 2_048) / 48_000.0); + while available >= period { + let captured = now - Duration::from_secs_f64(f64::from(available) / 48_000.0); + packer.discard_capture_gap(captured, /*rate*/ 48_000.0); + for offset in (0..period as usize).step_by(BLOCK) { + let frame = Frame { + samples: [0.25; BLOCK], + len: BLOCK.min(period as usize - offset), + at: captured + Duration::from_secs_f64(offset as f64 / 48_000.0), + generation: 2, + }; + assert!(packer.push(frame, /*rate*/ 48_000.0, &buffers.capture)); + } + available -= period; + while let Some(frame) = buffers.capture.pop() { + packets.extend(processor.capture(&frame, || now).unwrap()); + } + } + } + assert!(packets.len() >= 90, "capture starved Opus"); + + // Queue a full output callback without racing the producer. + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + buffers.serviced.store(true, Ordering::Release); + let port = PlaybackPort::new(buffers.clone(), /*rate*/ 48_000); + let writer = port.writer(); + let bytes = 0.25_f32.to_le_bytes().repeat(period as usize); + for chunk in bytes.chunks(BLOCK * 4) { + assert_eq!(writer.write(chunk).unwrap(), chunk.len()); + } + let mut output = vec![0.0_f32; period as usize]; + render_output( + &mut output, + /*channels*/ 1, + /*rate*/ 48_000.0, + Instant::now(), + &buffers, + &mut OutputState::default(), + ); + assert_eq!(output, vec![0.25; period as usize]); +} + +#[test] +fn callback_configuration_fits_dynamic_queue_with_following_callbacks() { + for min in [6_016, 8_192] { + let supported = cpal::SupportedStreamConfig::new( + /*channels*/ 1, + 384_000, + cpal::SupportedBufferSize::Range { min, max: 16_384 }, + cpal::SampleFormat::F32, + ); + let cpal::BufferSize::Fixed(frames) = + bounded_stream_config(&supported).unwrap().buffer_size + else { + panic!("callback size must be bounded"); + }; + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000); + buffers.serviced.store(true, Ordering::Release); + let mut output = OutputState::default(); + let start = Instant::now(); + // An existing partial block, a full callback, then 5 ms of 128-frame callbacks. + for count in [BLOCK - 1, frames as usize].into_iter().chain([128; 15]) { + render_output( + &mut vec![0.0_f32; count], + /*channels*/ 1, + /*rate*/ 384_000.0, + start, + &buffers, + &mut output, + ); + } + assert_eq!( + buffers.rendered.len(), + (BLOCK - 1 + frames as usize + 15 * 128) / BLOCK, + ); + assert!(buffers.rendered.len() < buffers.rendered.capacity()); + assert!(!buffers.failed.load(Ordering::Acquire)); + } +} + +#[test] +fn unsupported_callback_ranges_do_not_fall_back_to_default() { + for range in [ + cpal::SupportedBufferSize::Unknown, + cpal::SupportedBufferSize::Range { min: 0, max: 0 }, + cpal::SupportedBufferSize::Range { + min: 1_024, + max: 512, + }, + cpal::SupportedBufferSize::Range { + min: 8_193, + max: 16_384, + }, + ] { + let supported = cpal::SupportedStreamConfig::new( + /*channels*/ 2, + 96_000, + range, + cpal::SampleFormat::F32, + ); + assert!(bounded_stream_config(&supported).is_err(), "{range:?}"); + } +} + +#[test] +fn callback_timing_must_fit_service_and_stale_limits() { + for (rate, min, max) in [(8_000, 8_192, 8_192), (8_000, 7_704, 8_192)] { + let supported = cpal::SupportedStreamConfig::new( + /*channels*/ 2, + rate, + cpal::SupportedBufferSize::Range { min, max }, + cpal::SampleFormat::F32, + ); + assert!( + bounded_stream_config(&supported).is_err(), + "rate={rate}, range={min}..={max}", + ); + } +} + +#[test] +fn callback_pair_must_fit_the_processing_deadline() { + for (input_rate, input_frames, output_rate, output_frames, accepted) in [ + (8_000, 7_680, 48_000, 480, false), + (8_000, 2_000, 8_000, 2_000, false), + (48_000, 480, 48_000, 480, true), + (44_100, 441, 48_000, 480, true), + (8_000, 1_600, 48_000, 480, true), + ] { + for (rate, frames) in [(input_rate, input_frames), (output_rate, output_frames)] { + let supported = cpal::SupportedStreamConfig::new( + /*channels*/ 1, + rate, + cpal::SupportedBufferSize::Range { + min: frames, + max: frames, + }, + cpal::SampleFormat::F32, + ); + assert!(bounded_stream_config(&supported).is_ok()); + } + let processor = processing::Processor::new(input_rate, output_rate).unwrap(); + assert_eq!( + processor + .validate_callback_timing(input_frames, output_frames) + .is_ok(), + accepted, + "input={input_rate}/{input_frames}, output={output_rate}/{output_frames}", + ); + } +} + +#[test] +fn tiny_output_callbacks_pack_references_without_delaying_rendering() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000); + buffers.serviced.store(true, Ordering::Release); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + let generation = buffers.speaker.load(Ordering::Acquire); + let start = Instant::now(); + for _ in 0..8 { + assert!( + buffers + .push_playback(Frame { + samples: [0.5; BLOCK], + len: BLOCK, + at: start, + generation, + }) + .is_ok() + ); + } + let mut state = OutputState::default(); + for callback in 0..120 { + let mut output = [0.0_f32; 32]; + render_output( + &mut output, + /*channels*/ 2, + /*rate*/ 384_000.0, + start + Duration::from_secs_f64((callback * 16) as f64 / 384_000.0), + &buffers, + &mut state, + ); + assert_eq!(output, [0.5; 32]); + } + for sample in [-0.75, 0.25] { + record_peak(&buffers.microphone_peak, sample); + } + assert_eq!( + buffers.take_state().unwrap(), + codex_realtime_webrtc::AudioState { + microphone_peak: 49151, + speaker_peak: 32767, + } + ); + assert_eq!(buffers.take_state().unwrap(), Default::default()); + record_peak(&buffers.microphone_peak, /*sample*/ 1.5); + assert_eq!( + buffers.take_state().unwrap(), + codex_realtime_webrtc::AudioState { + microphone_peak: u16::MAX, + speaker_peak: 0, + } + ); + assert_eq!(buffers.rendered.len(), 7); + assert!(!buffers.failed.load(Ordering::Acquire)); +} + +#[test] +fn rendered_queue_retains_high_rate_callbacks_during_bounded_service_pause() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000); + buffers.serviced.store(true, Ordering::Release); + let mut state = OutputState::default(); + let start = Instant::now(); + let samples = 384_000 * 63 / 100; + for offset in (0..samples).step_by(17) { + let mut output = [1.0_f32; 34]; + let len = (samples - offset).min(17) * 2; + render_output( + &mut output[..len], + /*channels*/ 2, + /*rate*/ 384_000.0, + start + Duration::from_secs_f64(offset as f64 / 384_000.0), + &buffers, + &mut state, + ); + assert_eq!(&output[..len], &[0.0; 34][..len]); + assert!(!buffers.failed.load(Ordering::Acquire)); + } + assert_eq!(buffers.rendered.len(), samples / BLOCK); +} + +#[test] +fn suppressed_output_does_not_fail_when_reference_queue_is_full() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 384_000); + buffers.serviced.store(true, Ordering::Release); + let mut state = OutputState::default(); + let mut output = [1.0_f32; BLOCK]; + for _ in 0..buffers.rendered.capacity() * 3 { + render_output( + &mut output, + /*channels*/ 1, + /*rate*/ 384_000.0, + Instant::now(), + &buffers, + &mut state, + ); + assert_eq!(output, [0.0; BLOCK]); + } + assert_eq!(buffers.rendered.len(), buffers.rendered.capacity()); + assert!(!buffers.failed.load(Ordering::Acquire)); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + render_output( + &mut output, + /*channels*/ 1, + /*rate*/ 384_000.0, + Instant::now(), + &buffers, + &mut state, + ); + assert!(!buffers.failed.load(Ordering::Acquire)); + assert!(buffers.render_dropped.load(Ordering::Acquire)); +} + +#[test] +fn stream_xruns_recover_without_clearing_device_failure() { + let buffers = Buffers::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000); + handle_stream_error(&buffers, cpal::ErrorKind::Xrun.into()); + assert!(!buffers.failed.load(Ordering::Acquire)); + handle_stream_error(&buffers, cpal::ErrorKind::DeviceNotAvailable.into()); + assert!(buffers.failed.load(Ordering::Acquire)); + handle_stream_error(&buffers, cpal::ErrorKind::Xrun.into()); + assert!(buffers.failed.load(Ordering::Acquire)); +} diff --git a/codex-rs/voice-host/src/devices_unavailable.rs b/codex-rs/voice-host/src/devices_unavailable.rs new file mode 100644 index 0000000000000000000000000000000000000000..03af795eb185038923fc8071b9d3b9738fa90ec8 --- /dev/null +++ b/codex-rs/voice-host/src/devices_unavailable.rs @@ -0,0 +1,32 @@ +//! Keep non-native helper targets buildable without linking a host audio backend. + +use std::io; + +pub(super) struct Devices; + +impl Devices { + pub(super) fn receive(&self, _: crate::incoming::ReceivedRtp) -> io::Result<()> { + Self::open().map(|_| ()) + } + + pub(super) fn take_state(&self) -> io::Result { + Self::open().map(|_| codex_realtime_webrtc::AudioState::default()) + } + + pub(super) fn open() -> io::Result { + Err(io::Error::other( + "audio devices unavailable for this helper target", + )) + } + + pub(super) fn set_controls(&self, _: codex_realtime_webrtc::AudioControls) -> io::Result<()> { + Self::open().map(|_| ()) + } + + pub(super) async fn service( + &mut self, + _: &mut crate::audio_track::AudioTrack, + ) -> io::Result { + Self::open().map(|_| 0) + } +} diff --git a/codex-rs/voice-host/src/incoming.rs b/codex-rs/voice-host/src/incoming.rs new file mode 100644 index 0000000000000000000000000000000000000000..89c46d1fffdb59bc1b7dfee164a8df057d8968f1 --- /dev/null +++ b/codex-rs/voice-host/src/incoming.rs @@ -0,0 +1,167 @@ +//! Takes incoming Opus RTP before the upstream track queue, preserving network arrival time. +//! Packet and byte permits follow owned data through the consumer, not merely through this queue. +//! Stale or saturated media is discarded so a slow worker can resume with fresh speaker audio. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; + +use rtc::interceptor::Interceptor; +use rtc::interceptor::NoopInterceptor; +use rtc::interceptor::Packet; +use rtc::interceptor::StreamInfo; +use rtc::interceptor::TaggedPacket; +use rtc::interceptor::interceptor; +use rtc::sansio; +use rtc::shared::error::Error; +use rtc::shared::marshal::Marshal; +use rtc::shared::marshal::MarshalSize; +use tokio::sync::OwnedSemaphorePermit; +use tokio::sync::Semaphore; +use tokio::sync::mpsc; + +const PACKETS: usize = 64; +const BYTES: usize = 2 * 1024 * 1024; +const PACKET_BYTES: usize = 64 * 1024; + +struct State { + epoch: AtomicU64, + failed: AtomicBool, + packets: Arc, + bytes: Arc, +} + +pub(crate) struct ReceivedRtp { + data: Vec, + pub(crate) at: Instant, + epoch: u64, + _packet: OwnedSemaphorePermit, + _bytes: OwnedSemaphorePermit, +} + +impl AsRef<[u8]> for ReceivedRtp { + fn as_ref(&self) -> &[u8] { + &self.data + } +} + +pub(crate) struct Incoming { + state: Arc, + receiver: mpsc::Receiver, +} + +#[derive(Interceptor)] +pub(crate) struct Ingress { + #[next] + next: NoopInterceptor, + state: Arc, + sender: mpsc::Sender, + stream: Option, +} + +impl Incoming { + pub(crate) fn new() -> (Self, Ingress) { + let state = Arc::new(State { + epoch: AtomicU64::new(/*v*/ 1), + failed: AtomicBool::new(false), + packets: Arc::new(Semaphore::new(/*permits*/ PACKETS)), + bytes: Arc::new(Semaphore::new(/*permits*/ BYTES)), + }); + let (sender, receiver) = mpsc::channel(/*buffer*/ PACKETS); + ( + Self { + state: state.clone(), + receiver, + }, + Ingress { + next: NoopInterceptor::new(), + state, + sender, + stream: None, + }, + ) + } + + pub(crate) fn set_suppressed(&self, suppressed: bool) -> Result<(), &'static str> { + let epoch = self.state.epoch.load(Ordering::Acquire); + if (epoch % 2 == 1) != suppressed { + self.state.epoch.store( + epoch.checked_add(1).ok_or("audio epoch exhausted")?, + Ordering::Release, + ); + } + Ok(()) + } + + pub(crate) fn take(&mut self) -> Result, &'static str> { + if self.state.failed.load(Ordering::Acquire) { + return Err("incoming audio failed"); + } + let epoch = self.state.epoch.load(Ordering::Acquire); + for _ in 0..PACKETS { + let Ok(packet) = self.receiver.try_recv() else { + return Ok(None); + }; + if epoch.is_multiple_of(2) && packet.epoch == epoch { + if packet.at.elapsed() > Duration::from_secs(/*secs*/ 1) { + continue; + } + return Ok(Some(packet)); + } + } + Ok(None) + } +} + +#[interceptor] +impl Ingress { + #[overrides] + fn handle_read(&mut self, message: TaggedPacket) -> Result<(), Self::Error> { + let Packet::Rtp(packet) = message.message else { + return Ok(()); + }; + let epoch = self.state.epoch.load(Ordering::Acquire); + if epoch % 2 == 1 || self.state.failed.load(Ordering::Acquire) { + return Ok(()); + } + let size = packet.marshal_size(); + if size > PACKET_BYTES + || packet.header.payload_type != crate::audio_track::OPUS_PAYLOAD_TYPE + || self.stream.is_some_and(|ssrc| ssrc != packet.header.ssrc) + { + self.state.failed.store(true, Ordering::Release); + return Ok(()); + } + self.stream = Some(packet.header.ssrc); + if message.now.elapsed() > Duration::from_secs(/*secs*/ 1) { + return Ok(()); + } + let Ok(packet_permit) = self.state.packets.clone().try_acquire_owned() else { + return Ok(()); + }; + let Ok(bytes_permit) = self.state.bytes.clone().try_acquire_many_owned(size as u32) else { + return Ok(()); + }; + let mut data = vec![0; size]; + if packet.marshal_to(&mut data).ok() != Some(size) { + self.state.failed.store(true, Ordering::Release); + return Ok(()); + } + let _ = self.sender.try_send(ReceivedRtp { + data, + at: message.now, + epoch, + _packet: packet_permit, + _bytes: bytes_permit, + }); + // Consume here: no second copy is retained by the upstream track-event queue. + Ok(()) + } +} + +#[cfg(test)] +#[path = "incoming_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/incoming_tests.rs b/codex-rs/voice-host/src/incoming_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..70d6048e21ea8769b4ee707222b57257266bd6cf --- /dev/null +++ b/codex-rs/voice-host/src/incoming_tests.rs @@ -0,0 +1,119 @@ +use super::*; +use pretty_assertions::assert_eq; +use rtc::rtp::header::Header; +use rtc::sansio::Protocol; + +fn pair() -> (Incoming, Ingress) { + let (incoming, ingress) = Incoming::new(); + incoming.set_suppressed(/*suppressed*/ false).unwrap(); + (incoming, ingress) +} + +fn packet(size: usize, at: Instant) -> TaggedPacket { + TaggedPacket { + now: at, + transport: Default::default(), + message: Packet::Rtp(rtc::rtp::Packet { + header: Header { + version: 2, + payload_type: 111, + ssrc: 7, + ..Default::default() + }, + payload: vec![5; size - 12].into(), + }), + } +} + +#[test] +fn arrival_and_rtp_bytes_survive_the_adapter_without_an_upstream_copy() { + let (mut incoming, mut ingress) = pair(); + let at = Instant::now() - Duration::from_millis(/*millis*/ 20); + let input = packet(/*size*/ 32, at); + let Packet::Rtp(expected) = &input.message else { + panic!() + }; + let expected = expected.marshal().unwrap(); + ingress.handle_read(input).unwrap(); + let received = incoming.take().unwrap().unwrap(); + assert_eq!((received.at, received.as_ref()), (at, expected.as_ref())); + assert!(ingress.poll_read().is_none()); +} + +#[test] +fn exhausted_packet_or_byte_budget_drops_audio_then_accepts_fresh_packets() { + for size in [32, PACKET_BYTES] { + let (mut incoming, mut ingress) = pair(); + let count = PACKETS.min(BYTES / size); + let mut held = Vec::new(); + for _ in 0..count { + ingress.handle_read(packet(size, Instant::now())).unwrap(); + held.push(incoming.take().unwrap().unwrap()); + } + drop(held.pop()); + ingress.handle_read(packet(size, Instant::now())).unwrap(); + held.push(incoming.take().unwrap().unwrap()); + ingress.handle_read(packet(size, Instant::now())).unwrap(); + assert!(incoming.take().unwrap().is_none()); + assert!(!incoming.state.failed.load(Ordering::Acquire)); + drop(held); + ingress.handle_read(packet(size, Instant::now())).unwrap(); + assert!(incoming.take().unwrap().is_some()); + assert_eq!( + ( + incoming.state.packets.available_permits(), + incoming.state.bytes.available_permits() + ), + (PACKETS, BYTES) + ); + } +} + +#[test] +fn old_speaker_packets_are_discarded_without_poisoning_fresh_audio() { + let (mut incoming, mut ingress) = pair(); + ingress + .handle_read(packet(/*size*/ 32, Instant::now())) + .unwrap(); + let mut queued = incoming.receiver.try_recv().unwrap(); + queued.at -= Duration::from_secs(/*secs*/ 2); + ingress.sender.try_send(queued).unwrap(); + assert!(incoming.take().unwrap().is_none()); + ingress + .handle_read(packet(/*size*/ 32, Instant::now())) + .unwrap(); + assert!(incoming.take().unwrap().is_some()); +} + +#[test] +fn suppression_discards_queued_and_in_flight_old_epochs() { + let (mut incoming, mut ingress) = pair(); + ingress + .handle_read(packet(/*size*/ 32, Instant::now())) + .unwrap(); + incoming.set_suppressed(/*suppressed*/ true).unwrap(); + ingress + .handle_read(packet(/*size*/ 32, Instant::now())) + .unwrap(); + incoming.set_suppressed(/*suppressed*/ false).unwrap(); + assert!(incoming.take().unwrap().is_none()); + ingress + .handle_read(packet(/*size*/ 32, Instant::now())) + .unwrap(); + assert!(incoming.take().unwrap().is_some()); +} + +#[test] +fn invalid_stream_and_oversize_packets_fail_without_returning_media() { + for size in [32, PACKET_BYTES + 1] { + let (mut incoming, mut ingress) = pair(); + let mut input = packet(size, Instant::now()); + if size == 32 + && let Packet::Rtp(packet) = &mut input.message + { + packet.header.payload_type = 96; + } + ingress.handle_read(input).unwrap(); + assert_eq!(incoming.take().err(), Some("incoming audio failed")); + } +} diff --git a/codex-rs/voice-host/src/main.rs b/codex-rs/voice-host/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..709521aa0e1e38a8c265fbc12b321f2ad85a74ed --- /dev/null +++ b/codex-rs/voice-host/src/main.rs @@ -0,0 +1,281 @@ +//! Same-build helper lifecycle with privately owned runtime, transport and opt-in local devices. +//! Queued privacy controls take priority over starting another capture batch. + +// Non-native helper targets negotiate transport but have no device backend to send audio. +#[cfg_attr( + not(any( + target_os = "macos", + all(target_os = "linux", target_env = "gnu"), + all(windows, target_env = "msvc") + )), + allow(dead_code) +)] +mod audio_track; +#[cfg_attr( + not(any( + target_os = "macos", + all(target_os = "linux", target_env = "gnu"), + all(windows, target_env = "msvc") + )), + path = "devices_unavailable.rs" +)] +mod devices; +mod incoming; +mod runtime; +mod service_failure; +mod transport; +mod transport_runtime; + +use std::cell::Cell; +use std::io; +use std::io::Write; +use std::sync::mpsc; +use std::time::Duration; + +use codex_realtime_webrtc::HelperExitStage; +use codex_realtime_webrtc::Message; +use codex_realtime_webrtc::encode_frame; +use codex_realtime_webrtc::read_message; +use service_failure::ServiceFailure; + +const DEVICE_SERVICE_INTERVAL: Duration = Duration::from_millis(/*millis*/ 5); + +const BUILD_COMMIT: &str = match option_env!("STABLE_GIT_COMMIT") { + Some(commit) => commit, + None => "dev", +}; + +fn main() { + codex_process_hardening::pre_main_hardening(); + let mut args = std::env::args_os().skip(/*n*/ 1); + match (args.next(), args.next()) { + (Some(arg), None) if arg == "--build-commit" => println!("{BUILD_COMMIT}"), + (None, None) => { + let phase = Cell::new(HelperExitStage::ControlSequence); + if run(&phase, |executor| { + phase.set(HelperExitStage::Transport); + executor + .block_on(transport::Transport::new()) + .map_err(io::Error::other) + }) + .is_err() + { + std::process::exit(phase.get().code()); + } + } + _ => std::process::exit(/*code*/ 2), + } +} + +fn run( + phase: &Cell, + start_transport: impl Fn(&tokio::runtime::Runtime) -> io::Result, +) -> io::Result<()> { + let (sender, receiver) = mpsc::sync_channel(/*bound*/ 1); + std::thread::Builder::new() + .name("voice-control".into()) + .spawn(move || { + let mut input = io::stdin().lock(); + loop { + match read_message(&mut input) { + Ok(Some(message)) => { + if sender.try_send(message).is_err() { + std::process::exit(HelperExitStage::ControlQueue.code()); + } + } + Ok(None) => break, + Err(_) => std::process::exit(HelperExitStage::ControlRead.code()), + } + } + drop(sender); + // Independent of the main worker or a blocked stdout write, including after parent death. + std::thread::sleep(Duration::from_secs(/*secs*/ 2)); + std::process::exit(HelperExitStage::ParentGone.code()); + })?; + let Ok(hello) = receiver.recv() else { + return Ok(()); + }; + if hello + != (Message::Hello { + protocol: 1, + build_commit: BUILD_COMMIT.to_owned(), + }) + { + return Err(io::Error::other("incompatible voice helper")); + } + phase.set(HelperExitStage::Reply); + let mut output = io::stdout().lock(); + output.write_all(&encode_frame(&Message::Ready {})?)?; + output.flush()?; + let mut runtime = None; + let executor = tokio::runtime::Runtime::new()?; + let mut transport: Option = None; + let mut answered = false; + let mut devices: Option = None; + loop { + phase.set(HelperExitStage::ControlSequence); + let message = if let Some(devices) = &mut devices { + let peer = transport + .as_mut() + .ok_or_else(|| io::Error::other("voice peer not started"))?; + wait_for_control(&receiver, || { + // Bound one pass to the ingress queue capacity so new media + // cannot indefinitely postpone a waiting privacy control. + for _ in 0..64 { + phase.set(HelperExitStage::AudioIngress); + let Some(packet) = peer.incoming.take().map_err(io::Error::other)? else { + break; + }; + phase.set(HelperExitStage::Playout); + devices.receive(packet)?; + } + phase.set(HelperExitStage::AudioService); + if let Err(error) = executor.block_on(devices.service(&mut peer.audio)) { + if let Some(failure) = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + { + phase.set(match failure { + ServiceFailure::Playout => HelperExitStage::Playout, + ServiceFailure::Render => HelperExitStage::Render, + ServiceFailure::Capture => HelperExitStage::Capture, + ServiceFailure::Device => HelperExitStage::Device, + ServiceFailure::Send => HelperExitStage::Send, + }); + } + return Err(error); + } + Ok(()) + })? + .ok_or(mpsc::RecvTimeoutError::Disconnected) + } else { + receiver + .recv() + .map_err(|_| mpsc::RecvTimeoutError::Disconnected) + }; + let reply = match message { + Ok(Message::InspectAudio {}) => { + phase.set(HelperExitStage::InspectAudio); + if answered && transport.as_ref().is_none_or(|peer| !*peer.ready.borrow()) { + return Err(io::Error::other("voice connection closed")); + } + Message::AudioState { + state: devices + .as_ref() + .map(devices::Devices::take_state) + .transpose() + .inspect_err(|_| phase.set(HelperExitStage::Device))? + .unwrap_or_default(), + } + } + Ok(Message::OpenDevices {}) if devices.is_none() && runtime.is_some() && answered => { + phase.set(HelperExitStage::OpenDevices); + devices = Some(devices::Devices::open()?); + Message::DevicesOpened {} + } + Ok(Message::SetAudioControls { controls }) => { + phase.set(HelperExitStage::AudioControls); + let peer = transport + .as_ref() + .ok_or_else(|| io::Error::other("voice peer not started"))?; + if controls.speaker_suppressed { + peer.incoming + .set_suppressed(controls.speaker_suppressed) + .map_err(io::Error::other)?; + } + devices + .as_mut() + .ok_or_else(|| io::Error::other("audio devices not open"))? + .set_controls(controls)?; + if !controls.speaker_suppressed { + peer.incoming + .set_suppressed(controls.speaker_suppressed) + .map_err(io::Error::other)?; + } + Message::AudioControlsApplied {} + } + Ok(Message::StartTransport {}) if transport.is_none() => { + phase.set(HelperExitStage::Transport); + let peer = start_transport(&executor)?; + let sdp = executor.block_on(peer.offer()).map_err(io::Error::other)?; + transport = Some(peer); + Message::Offer { + sdp: sdp.try_into().map_err(io::Error::other)?, + } + } + Ok(Message::ApplyAnswer { sdp }) if !answered => { + phase.set(HelperExitStage::Transport); + let Some(peer) = transport.as_ref() else { + return Err(io::Error::other("voice transport not started")); + }; + let outcome = executor + .block_on(peer.apply_answer(sdp.into_sdp())) + .map_err(io::Error::other)?; + if outcome == transport::AnswerOutcome::TimedOut { + // The client reaps this helper before considering a fresh negotiation. + output.write_all(&encode_frame(&Message::TransportTimedOut {})?)?; + output.flush()?; + return Ok(()); + } + answered = true; + Message::TransportReady {} + } + Ok(Message::InitializeRuntime {}) => { + phase.set(HelperExitStage::Runtime); + if runtime.is_some() { + return Err(io::Error::other("runtime already initialized")); + } + runtime = Some(runtime::Runtime::initialize()?); + Message::RuntimeReady {} + } + Ok(Message::Close {}) => { + phase.set(HelperExitStage::Shutdown); + let _ = devices.take(); + if let Some(mut peer) = transport.take() { + executor.block_on(peer.close()).map_err(io::Error::other)?; + } + output.write_all(&encode_frame(&Message::Closed {})?)?; + return output.flush(); + } + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()), + Ok( + Message::Hello { .. } + | Message::Ready {} + | Message::RuntimeReady {} + | Message::StartTransport {} + | Message::ApplyAnswer { .. } + | Message::Offer { .. } + | Message::TransportReady {} + | Message::TransportTimedOut {} + | Message::OpenDevices {} + | Message::DevicesOpened {} + | Message::AudioControlsApplied {} + | Message::AudioState { .. } + | Message::Closed {}, + ) => return Err(io::Error::other("invalid voice control sequence")), + }; + phase.set(HelperExitStage::Reply); + output.write_all(&encode_frame(&reply)?)?; + output.flush()?; + } +} + +// Pending privacy controls and shutdown always precede the next capture batch. +// A command arriving after service begins cannot retract an already in-flight packet. +fn wait_for_control( + receiver: &mpsc::Receiver, + mut service: impl FnMut() -> io::Result<()>, +) -> io::Result> { + loop { + match receiver.recv_timeout(DEVICE_SERVICE_INTERVAL) { + Ok(message) => return Ok(Some(message)), + Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(None), + Err(mpsc::RecvTimeoutError::Timeout) => service()?, + } + } +} + +#[cfg(test)] +#[path = "main_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/main_tests.rs b/codex-rs/voice-host/src/main_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..8dca96433c4e7a717098600c1c93fc7092be0391 --- /dev/null +++ b/codex-rs/voice-host/src/main_tests.rs @@ -0,0 +1,132 @@ +//! Verify control-first capture servicing and the real parent-loss watchdog during blocked startup. + +use super::*; +use std::process::Stdio; + +use pretty_assertions::assert_eq; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio::time::timeout; + +const STARTUP_BLOCKED: &[u8] = b"voice startup blocked\n"; +const CHILD_ENV: &str = "CODEX_VOICE_WATCHDOG_TEST_CHILD"; + +#[test] +fn queued_privacy_controls_and_close_precede_capture_service() { + for message in [ + Message::SetAudioControls { + controls: codex_realtime_webrtc::AudioControls { + microphone_muted: true, + speaker_suppressed: true, + }, + }, + Message::Close {}, + ] { + let expected = encode_frame(&message).unwrap(); + let (sender, receiver) = mpsc::sync_channel(/*bound*/ 1); + sender.send(message).unwrap(); + let next = wait_for_control(&receiver, || panic!("capture preceded queued control")) + .unwrap() + .unwrap(); + assert_eq!(encode_frame(&next).unwrap(), expected); + drop(sender); + assert!( + wait_for_control(&receiver, || panic!("capture after parent loss")) + .unwrap() + .is_none() + ); + } +} + +#[test] +fn idle_service_rechecks_controls_before_another_batch() { + let (sender, receiver) = mpsc::sync_channel(/*bound*/ 1); + let mut services = 0; + let next = wait_for_control(&receiver, || { + services += 1; + sender.send(Message::Close {}).unwrap(); + Ok(()) + }) + .unwrap() + .unwrap(); + assert_eq!(next, Message::Close {}); + assert_eq!(services, 1); +} + +#[test] +#[ignore = "subprocess fixture for the parent-loss watchdog test"] +fn blocked_startup_fixture() { + if std::env::var_os(CHILD_ENV).as_deref() != Some(std::ffi::OsStr::new("1")) { + return; + } + run(&Cell::new(HelperExitStage::Transport), |_| { + io::stderr().write_all(STARTUP_BLOCKED)?; + io::stderr().flush()?; + loop { + std::thread::park(); + } + }) + .unwrap(); +} + +#[tokio::test] +async fn parent_pipe_loss_terminates_helper_with_blocked_transport_startup() { + timeout(Duration::from_secs(/*secs*/ 10), async { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tests::blocked_startup_fixture", + "--ignored", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + input + .write_all( + &encode_frame(&Message::Hello { + protocol: 1, + build_commit: BUILD_COMMIT.to_owned(), + }) + .unwrap(), + ) + .await + .unwrap(); + let expected = encode_frame(&Message::Ready {}).unwrap(); + let mut prefix = Vec::new(); + // libtest writes a short harness prefix before the helper's framed output. + while !prefix.ends_with(&expected) { + assert!(prefix.len() < 512, "missing helper ready frame"); + prefix.push(child.stdout.as_mut().unwrap().read_u8().await.unwrap()); + } + input + .write_all(&encode_frame(&Message::StartTransport {}).unwrap()) + .await + .unwrap(); + let mut marker = vec![0; STARTUP_BLOCKED.len()]; + child + .stderr + .as_mut() + .unwrap() + .read_exact(&mut marker) + .await + .unwrap(); + assert_eq!(marker, STARTUP_BLOCKED); + assert!(child.try_wait().unwrap().is_none()); + drop(input); + let output = child.wait_with_output().await.unwrap(); + assert_eq!( + output.status.code(), + Some(HelperExitStage::ParentGone.code()) + ); + assert_eq!((output.stdout, output.stderr), (vec![], vec![])); + }) + .await + .unwrap(); +} diff --git a/codex-rs/voice-host/src/playback.rs b/codex-rs/voice-host/src/playback.rs new file mode 100644 index 0000000000000000000000000000000000000000..2fffbfc6b40191f00180f078a17cadb86e862f63 --- /dev/null +++ b/codex-rs/voice-host/src/playback.rs @@ -0,0 +1,155 @@ +//! Bounded mono F32LE writes for the native sink, never called from a device callback. +//! Each writer belongs to one speaker epoch. Reset cancels it before locking the producer. +//! Delay queries retain the last coherent estimate during brief callback contention. + +use super::buffers::BLOCK; +use super::buffers::Buffers; +use super::buffers::Frame; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::PoisonError; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; + +pub(super) struct PlaybackState { + pub(super) producer: Mutex<()>, + buffers: Arc, + rate: u32, +} + +pub(super) struct PlaybackPort(pub(super) Arc); + +pub(crate) struct PlaybackWriter { + state: Arc, + epoch: u64, + last_delay: AtomicU32, +} + +impl PlaybackPort { + pub(super) fn new(buffers: Arc, rate: u32) -> Self { + Self(Arc::new(PlaybackState { + producer: Mutex::new(()), + buffers, + rate, + })) + } + + pub(super) fn writer(&self) -> PlaybackWriter { + PlaybackWriter { + state: self.0.clone(), + epoch: self.0.buffers.speaker.load(Ordering::Acquire), + last_delay: AtomicU32::new(/*v*/ 0), + } + } +} + +impl PlaybackWriter { + pub(crate) fn fail_if_current(&self) { + let buffers = &self.state.buffers; + let _transition = buffers + .speaker_failure_gate + .lock() + .unwrap_or_else(PoisonError::into_inner); + if buffers.speaker.load(Ordering::Acquire) == self.epoch { + buffers.failed.store(true, Ordering::Release); + } + } + + pub(crate) fn rate(&self) -> u32 { + self.state.rate + } + + pub(crate) fn write(&self, bytes: &[u8]) -> Result { + if bytes.is_empty() || !bytes.len().is_multiple_of(4) { + return Err("invalid speaker buffer"); + } + let mut frame = Frame { + samples: [0.0; BLOCK], + len: (bytes.len() / 4).min(BLOCK), + at: Instant::now(), + generation: self.epoch, + }; + for (sample, bytes) in frame.samples.iter_mut().zip(bytes.chunks_exact(4)) { + *sample = f32::from_le_bytes(bytes.try_into().map_err(|_| "invalid speaker buffer")?); + if !sample.is_finite() { + return Err("invalid speaker sample"); + } + } + let _producer = self + .state + .producer + .lock() + .map_err(|_| "speaker writer failed")?; + let deadline = Instant::now() + Duration::from_millis(/*millis*/ 100); + let buffers = &self.state.buffers; + // Linux requests 50 ms callbacks; retain two callbacks of audio so a + // callback can fill without racing the producer. Keep the queue cap. + let windows_per_second = if cfg!(target_os = "linux") { 10 } else { 25 }; + let limit = (self.state.rate / windows_per_second) + .min((BLOCK * buffers.playback.capacity()) as u32); + loop { + if self.epoch % 2 == 1 + || buffers.speaker.load(Ordering::Acquire) != self.epoch + || buffers.failed.load(Ordering::Acquire) + { + return Err("speaker writer cancelled"); + } + if buffers.queued.load(Ordering::Acquire) + frame.len as u32 <= limit + && !buffers.playback.is_full() + { + let bytes = frame.len * 4; + buffers + .push_playback(frame) + .map_err(|_| "speaker queue failed")?; + return Ok(bytes); + } + if Instant::now() >= deadline { + // The sink must consume this chunk to catch up with the device clock. + // Dropping it keeps the bounded queue and current epoch intact. + return Ok(frame.len * 4); + } + std::thread::park_timeout(Duration::from_millis(/*millis*/ 1)); + } + } + + pub(crate) fn delay(&self) -> u32 { + let buffers = &self.state.buffers; + let deadline = Instant::now() + Duration::from_millis(/*millis*/ 10); + loop { + let sequence = buffers.callback_sequence.load(Ordering::Acquire); + if buffers.speaker.load(Ordering::Acquire) != self.epoch || self.epoch % 2 == 1 { + return 0; + } + let remaining = + buffers.last_dac_ns.load(Ordering::Acquire).saturating_sub( + buffers.clock.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64, + ); + let hardware = + (u128::from(remaining) * u128::from(self.state.rate)).div_ceil(1_000_000_000); + let delay = buffers + .queued + .load(Ordering::Acquire) + .saturating_add(hardware.min(u128::from(u32::MAX)) as u32); + if sequence.is_multiple_of(2) + && sequence == buffers.callback_sequence.load(Ordering::Acquire) + { + self.last_delay.store(delay, Ordering::Relaxed); + return delay; + } + if Instant::now() >= deadline { + return if buffers.speaker.load(Ordering::Acquire) == self.epoch { + self.last_delay.load(Ordering::Relaxed) + } else { + 0 + }; + } + std::thread::yield_now(); + } + } +} + +#[cfg(test)] +#[path = "playback_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/playback_tests.rs b/codex-rs/voice-host/src/playback_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..70773a151918ca72edf4c53eed1027a065505e3c --- /dev/null +++ b/codex-rs/voice-host/src/playback_tests.rs @@ -0,0 +1,149 @@ +use super::*; +use crate::devices::buffers::Playback; +use pretty_assertions::assert_eq; + +fn active(rate: u32) -> (Arc, PlaybackPort, PlaybackWriter) { + let buffers = Arc::new(Buffers::new(rate, rate)); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + let port = PlaybackPort::new(buffers.clone(), rate); + let writer = port.writer(); + (buffers, port, writer) +} + +#[test] +fn partial_writes_account_for_samples_until_the_device_consumes_them() { + let (buffers, _, writer) = active(/*rate*/ 48000); + let samples: Vec<_> = (0..BLOCK + 20).map(|i| i as f32 / 512.0).collect(); + let bytes: Vec<_> = samples.iter().flat_map(|v| v.to_le_bytes()).collect(); + let first = writer.write(&bytes).unwrap(); + assert_eq!(first, BLOCK * 4); + assert_eq!(writer.write(&bytes[first..]).unwrap(), 20 * 4); + assert_eq!( + (writer.rate(), writer.delay()), + (48000, (BLOCK + 20) as u32) + ); + let mut playback = Playback::default(); + let actual: Vec<_> = (0..samples.len()) + .map(|_| playback.next(&buffers).unwrap()) + .collect(); + assert_eq!(actual, samples); + assert_eq!(writer.delay(), 0); + assert_eq!(playback.next(&buffers), None); +} + +#[test] +fn suppression_cancels_a_full_writer_and_old_writers_cannot_resume() { + let (buffers, port, writer) = active(/*rate*/ 8000); + let bytes = vec![0; BLOCK * 4]; + for _ in 0..if cfg!(target_os = "linux") { 3 } else { 1 } { + writer.write(&bytes).unwrap(); + } + let waiting = std::thread::spawn(move || writer.write(&bytes)); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap(); + assert_eq!(waiting.join().unwrap(), Err("speaker writer cancelled")); + let stale = port.writer(); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + assert_eq!(stale.write(&[0; 4]), Err("speaker writer cancelled")); + let mut playback = Playback::default(); + assert_eq!(playback.next(&buffers), None); + assert_eq!(buffers.queued.load(Ordering::Acquire), 0); + assert_eq!(port.writer().write(&[0; 4]), Ok(4)); +} + +#[test] +fn delay_includes_pending_dac_time_but_not_another_generation() { + let (buffers, _, writer) = active(/*rate*/ 48000); + writer.write(&[0; 40]).unwrap(); + let end = buffers.clock.elapsed() + Duration::from_millis(/*millis*/ 100); + buffers + .last_dac_ns + .store(end.as_nanos() as u64, Ordering::Release); + let delay = writer.delay(); + assert!((10..=4810).contains(&delay)); + assert!(delay > 10); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap(); + assert_eq!(writer.delay(), 0); +} + +#[test] +fn retired_sink_failure_cannot_fail_the_next_speaker_epoch() { + let (buffers, port, writer) = active(/*rate*/ 48_000); + let transition = buffers.speaker_failure_gate.lock().unwrap(); + let (started, ready) = std::sync::mpsc::sync_channel(/*bound*/ 0); + let failing = std::thread::spawn(move || { + started.send(()).unwrap(); + writer.fail_if_current(); + }); + ready.recv().unwrap(); + // Hold the same transition gate while retiring the old writer, then let + // the pending native failure inspect its now-stale epoch. + Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap(); + drop(transition); + failing.join().unwrap(); + assert!(!buffers.failed.load(Ordering::Acquire)); + + buffers.set_speaker_disabled(/*disabled*/ false).unwrap(); + port.writer().fail_if_current(); + assert!(buffers.failed.load(Ordering::Acquire)); +} + +#[test] +fn delay_contention_preserves_the_last_snapshot_without_failing_the_device() { + let (buffers, port, writer) = active(/*rate*/ 48000); + writer.write(&[0; 40]).unwrap(); + assert_eq!(writer.delay(), 10); + buffers + .callback_sequence + .store(/*val*/ 1, Ordering::Release); + let mut playback = Playback::default(); + let consumed: Vec<_> = (0..10).map(|_| playback.next(&buffers).unwrap()).collect(); + assert_eq!(consumed, vec![0.0; 10]); + assert_eq!( + (writer.delay(), buffers.failed.load(Ordering::Acquire)), + (10, false) + ); + buffers + .callback_sequence + .store(/*val*/ 2, Ordering::Release); + assert_eq!(writer.delay(), 0); + + writer.write(&[0; 20]).unwrap(); + assert_eq!(writer.delay(), 5); + buffers + .callback_sequence + .store(/*val*/ 3, Ordering::Release); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ true).unwrap(); + assert_eq!(writer.delay(), 0); + Buffers::set_disabled(&buffers.speaker, /*disabled*/ false).unwrap(); + assert_eq!( + ( + port.writer().delay(), + buffers.failed.load(Ordering::Acquire) + ), + (0, false) + ); +} + +#[test] +fn invalid_samples_fail_but_stalled_consumption_drops_and_resumes() { + let (buffers, _, writer) = active(/*rate*/ 8000); + assert_eq!( + writer.write(&f32::NAN.to_le_bytes()), + Err("invalid speaker sample") + ); + let blocks = if cfg!(target_os = "linux") { 3 } else { 1 }; + for _ in 0..blocks { + writer.write(&vec![0; BLOCK * 4]).unwrap(); + } + assert_eq!(writer.write(&vec![0; BLOCK * 4]), Ok(BLOCK * 4)); + assert_eq!( + buffers.queued.load(Ordering::Acquire), + (blocks * BLOCK) as u32 + ); + let mut playback = Playback::default(); + for _ in 0..blocks * BLOCK { + assert!(playback.next(&buffers).is_some()); + } + assert_eq!(writer.write(&vec![0; BLOCK * 4]), Ok(BLOCK * 4)); + assert!(!buffers.failed.load(Ordering::Acquire)); +} diff --git a/codex-rs/voice-host/src/playout.rs b/codex-rs/voice-host/src/playout.rs new file mode 100644 index 0000000000000000000000000000000000000000..60ba3f45bb1e579e7d88a802dd65fa0287f45378 --- /dev/null +++ b/codex-rs/voice-host/src/playout.rs @@ -0,0 +1,172 @@ +//! One explicit RTP jitter/decode pipeline per audible epoch; no autoplugging or device discovery. +//! Owned RTP memory retains ingress permits, and native bus messages never accumulate or escape. + +use super::audio_sink::Sink; +use super::playback::PlaybackWriter; +use crate::incoming::ReceivedRtp; +use gst::prelude::*; +use gstreamer as gst; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Instant; + +pub(super) struct Playout { + pipeline: gst::Pipeline, + source: gstreamer_app::AppSrc, + anchor: (Instant, gst::ClockTime), + failed: Arc, + latency: Arc, +} + +impl Playout { + pub(super) fn new(writer: PlaybackWriter) -> Result { + gst::init().map_err(|_| "audio runtime initialization failed")?; + let caps = gst::Caps::builder("audio/x-raw") + .field("format", "F32LE") + .field("layout", "interleaved") + .field("channels", 1i32) + .field("rate", writer.rate() as i32) + .build(); + let source = gstreamer_app::AppSrc::builder() + .is_live(true) + .format(gst::Format::Time) + .block(false) + .max_buffers(64) + .max_bytes(2 * 1024 * 1024) + .caps( + &gst::Caps::builder("application/x-rtp") + .field("media", "audio") + .field("encoding-name", "OPUS") + .field("clock-rate", 48000i32) + .field("payload", i32::from(crate::audio_track::OPUS_PAYLOAD_TYPE)) + .build(), + ) + .build(); + let jitter = gst::ElementFactory::make("rtpjitterbuffer") + .property("latency", 60u32) + .property_from_str("mode", "slave") + .property("drop-on-latency", true) + .property("do-lost", true) + .property("do-retransmission", false) + .build() + .map_err(|_| "jitter buffer unavailable")?; + let depay = gst::ElementFactory::make("rtpopusdepay") + .build() + .map_err(|_| "audio depayloader unavailable")?; + let decoder = gst::ElementFactory::make("opusdec") + .property("plc", true) + .property("use-inband-fec", false) + .build() + .map_err(|_| "audio decoder unavailable")?; + let convert = gst::ElementFactory::make("audioconvert") + .build() + .map_err(|_| "audio converter unavailable")?; + let resample = gst::ElementFactory::make("audioresample") + .build() + .map_err(|_| "audio resampler unavailable")?; + let filter = gst::ElementFactory::make("capsfilter") + .property("caps", caps) + .build() + .map_err(|_| "audio format filter unavailable")?; + let sink = Sink::new(writer); + let pipeline = gst::Pipeline::new(); + let elements = [ + source.upcast_ref(), + &jitter, + &depay, + &decoder, + &convert, + &resample, + &filter, + sink.upcast_ref(), + ]; + pipeline + .add_many(elements) + .map_err(|_| "audio pipeline assembly failed")?; + gst::Element::link_many(elements).map_err(|_| "audio pipeline linking failed")?; + pipeline.use_clock(Some(&gst::SystemClock::obtain())); + let failed = Arc::new(AtomicBool::new(false)); + let latency = Arc::new(AtomicBool::new(true)); + let failure = failed.clone(); + let changed = latency.clone(); + pipeline + .bus() + .ok_or("audio bus unavailable")? + .set_sync_handler(move |_, message| { + match message.view() { + gst::MessageView::Error(_) + | gst::MessageView::Eos(_) + | gst::MessageView::ClockLost(_) => { + failure.store(true, Ordering::Release); + } + gst::MessageView::Latency(_) => { + changed.store(true, Ordering::Release); + } + _ => {} + } + gst::BusSyncReply::Drop + }); + let mut playout = Self { + pipeline, + source, + anchor: (Instant::now(), gst::ClockTime::ZERO), + failed, + latency, + }; + playout + .pipeline + .set_state(gst::State::Playing) + .map_err(|_| "audio pipeline start failed")?; + playout.anchor = ( + Instant::now(), + playout + .pipeline + .current_running_time() + .ok_or("audio clock unavailable")?, + ); + Ok(playout) + } + + pub(super) fn push(&self, packet: ReceivedRtp) -> Result<(), &'static str> { + let Some(elapsed) = packet.at.checked_duration_since(self.anchor.0) else { + return Ok(()); + }; + let time = self + .anchor + .1 + .checked_add(gst::ClockTime::try_from(elapsed).map_err(|_| "audio timestamp overflow")?) + .ok_or("audio timestamp overflow")?; + let mut buffer = gst::Buffer::from_slice(packet); + buffer + .get_mut() + .ok_or("audio packet unexpectedly shared")? + .set_pts(time); + self.source + .push_buffer(buffer) + .map_err(|_| "audio pipeline rejected packet")?; + self.check() + } + + pub(super) fn check(&self) -> Result<(), &'static str> { + if self.failed.load(Ordering::Acquire) { + return Err("audio playback failed"); + } + if self.latency.swap(false, Ordering::AcqRel) { + self.pipeline + .recalculate_latency() + .map_err(|_| "audio latency negotiation failed")?; + } + Ok(()) + } +} + +impl Drop for Playout { + fn drop(&mut self) { + let _ = self.pipeline.set_state(gst::State::Null); + } +} + +#[cfg(test)] +#[path = "playout_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/playout_tests.rs b/codex-rs/voice-host/src/playout_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..f0b32308875d698ee9f6ba43798e44296512cb52 --- /dev/null +++ b/codex-rs/voice-host/src/playout_tests.rs @@ -0,0 +1,192 @@ +use super::*; +use crate::devices::buffers::Buffers; +use crate::devices::buffers::Playback; +use crate::devices::playback::PlaybackPort; +use crate::incoming::Incoming; +use gst::glib::subclass::prelude::ObjectSubclassIsExt; +use gstreamer_audio::subclass::prelude::AudioSinkImpl; +use pretty_assertions::assert_eq; +use rtc::interceptor::Packet; +use rtc::interceptor::TaggedPacket; +use rtc::sansio::Protocol; +use std::sync::atomic::AtomicUsize; +use std::sync::mpsc; +use std::time::Duration; + +#[test] +fn real_decoder_renders_current_rtp_and_rejects_pre_epoch_arrivals() { + gst::init().unwrap(); + let root = if codex_utils_cargo_bin::runfiles_available() { + let resource = format!( + "../../third_party/voice/native_link_{}_{}/runtime.json", + std::env::consts::OS, + std::env::consts::ARCH + ); + Some( + codex_utils_cargo_bin::find_resource!(resource) + .unwrap() + .parent() + .unwrap() + .to_path_buf(), + ) + } else { + std::env::var_os("CODEX_TEST_VOICE_RUNTIME").map(std::path::PathBuf::from) + }; + // Ordinary Cargo builds use installed plugins; explicit fixtures must load exactly. + if let Some(root) = root { + for plugin in + "app audioconvert audioresample coreelements opus rtp rtpmanager".split_whitespace() + { + let relative = if cfg!(target_os = "macos") { + format!("plugins/libgst{plugin}.dylib") + } else if cfg!(windows) { + format!("bin/gst{plugin}.dll") + } else { + format!("lib/gstreamer-1.0/libgst{plugin}.so") + }; + gst::Plugin::load_file(root.join(relative)).unwrap(); + } + } + let mut encoder = opus::Encoder::new( + /*sample_rate*/ 48000, + opus::Channels::Mono, + opus::Application::Voip, + ) + .unwrap(); + // Prepare media before the live clock starts so encoding cost cannot slow the sender. + let packets: Vec<_> = (0..40u16) + .map(|sequence| { + let samples: Vec<_> = (0..960) + .map(|n| ((usize::from(sequence) * 960 + n) as f32 * 0.07).sin() * 0.2) + .collect(); + let mut encoded = vec![0; 4096]; + let len = encoder.encode_float(&samples, &mut encoded).unwrap(); + encoded.truncate(len); + (sequence, encoded) + }) + .collect(); + let old = Instant::now(); + let buffers = Arc::new(Buffers::new( + /*input_rate*/ 48000, /*output_rate*/ 48000, + )); + buffers.set_speaker_disabled(/*disabled*/ false).unwrap(); + let port = PlaybackPort::new(buffers.clone(), /*rate*/ 48000); + let playout = Playout::new(port.writer()).unwrap(); + let (mut incoming, mut ingress) = Incoming::new(); + incoming.set_suppressed(/*suppressed*/ false).unwrap(); + let audible = AtomicUsize::new(0); + let last_callback_us = AtomicUsize::new(0); + let max_callback_gap_us = AtomicUsize::new(0); + let started = Instant::now(); + std::thread::scope(|scope| { + // Device callbacks run independently of incoming RTP work. + let (stop, stopping) = mpsc::channel::(); + let callback_buffers = buffers.clone(); + let callback_audible = &audible; + let last_callback_us = &last_callback_us; + let max_callback_gap_us = &max_callback_gap_us; + let consumer = scope.spawn(move || { + let mut output = Playback::default(); + let mut deadline = None; + loop { + match stopping.try_recv() { + Ok(end) => deadline = Some(end), + Err(mpsc::TryRecvError::Disconnected) => break, + Err(mpsc::TryRecvError::Empty) => {} + } + if deadline.is_some_and(|end| Instant::now() >= end) { + break; + } + // This checks real decoding, not a wall-clock device cadence. + // Drain available frames promptly so CI scheduling stalls do not + // trigger the native sink's production backpressure deadline. + if callback_buffers.queued.load(Ordering::Acquire) == 0 { + std::thread::park_timeout(Duration::from_millis(/*millis*/ 1)); + continue; + } + let count = (0..960) + .filter_map(|_| output.next(&callback_buffers)) + .filter(|sample| sample.abs() > 0.01) + .count(); + callback_audible.fetch_add(count, Ordering::Relaxed); + let completed_us = started.elapsed().as_micros() as usize; + let previous_us = last_callback_us.swap(completed_us, Ordering::Relaxed); + max_callback_gap_us.fetch_max(completed_us - previous_us, Ordering::Relaxed); + } + }); + for (sequence, encoded) in packets { + ingress + .handle_read(TaggedPacket { + now: Instant::now(), + transport: Default::default(), + message: Packet::Rtp(rtc::rtp::Packet { + header: rtc::rtp::header::Header { + version: 2, + payload_type: crate::audio_track::OPUS_PAYLOAD_TYPE, + sequence_number: sequence, + timestamp: u32::from(sequence) * 960, + ssrc: 7, + ..Default::default() + }, + payload: encoded.into(), + }), + }) + .unwrap(); + let mut packet = incoming.take().unwrap().unwrap(); + // Exercise the decoder epoch boundary independently of ingress age limits. + if sequence < 10 { + packet.at = old; + } + playout.push(packet).unwrap(); + // RTP advances by 20 ms per packet; avoid accumulating work and wakeup delays. + let next_packet = + started + Duration::from_millis(/*millis*/ (u64::from(sequence) + 1) * 20); + std::thread::sleep(next_packet.saturating_duration_since(Instant::now())); + if sequence == 9 { + assert_eq!(audible.load(Ordering::Relaxed), 0); + } + playout.check().unwrap(); + assert!( + !buffers.failed.load(Ordering::Acquire), + "packet {sequence}; elapsed={:?}, audible={}, queued={}, last_callback_us={}, max_callback_gap_us={}", + started.elapsed(), + audible.load(Ordering::Relaxed), + buffers.queued.load(Ordering::Acquire), + last_callback_us.load(Ordering::Relaxed), + max_callback_gap_us.load(Ordering::Relaxed) + ); + } + let deadline = Instant::now() + Duration::from_secs(/*secs*/ 3); + stop.send(deadline).unwrap(); + while audible.load(Ordering::Relaxed) <= 4800 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(/*millis*/ 20)); + playout.check().unwrap(); + assert!( + !buffers.failed.load(Ordering::Acquire), + "drain; elapsed={:?}, audible={}, queued={}, last_callback_us={}, max_callback_gap_us={}", + started.elapsed(), + audible.load(Ordering::Relaxed), + buffers.queued.load(Ordering::Acquire), + last_callback_us.load(Ordering::Relaxed), + max_callback_gap_us.load(Ordering::Relaxed) + ); + if Instant::now() >= deadline { + break; + } + } + drop(stop); + consumer.join().unwrap(); + let audible = audible.load(Ordering::Relaxed); + assert!( + audible > 4800, + "real decoded non-silent audio must reach the sink writer; observed {audible} samples" + ); + buffers.set_speaker_disabled(/*disabled*/ true).unwrap(); + drop(playout); + assert!(!buffers.failed.load(Ordering::Acquire)); + buffers.set_speaker_disabled(/*disabled*/ false).unwrap(); + let sink = Sink::new(port.writer()); + assert!(sink.imp().write(&f32::NAN.to_le_bytes()).is_err()); + assert!(buffers.failed.load(Ordering::Acquire)); + }); +} diff --git a/codex-rs/voice-host/src/processing.rs b/codex-rs/voice-host/src/processing.rs new file mode 100644 index 0000000000000000000000000000000000000000..6710be932e048bc2609196c6e6b61a5197b6a121 --- /dev/null +++ b/codex-rs/voice-host/src/processing.rs @@ -0,0 +1,270 @@ +//! Streaming device-rate conversion, echo/noise/gain processing, and 20 ms Opus encoding. +//! Mute transitions and capture gaps reset history; delayed pre-unmute buffers are discarded. + +use std::collections::VecDeque; +use std::time::Duration; +use std::time::Instant; + +use rubato::Resampler; +use rubato::audioadapter_buffers::direct::InterleavedSlice; +use sonora::AudioProcessing; +use sonora::StreamConfig; +use sonora::config::AdaptiveDigital; +use sonora::config::EchoCanceller; +use sonora::config::GainController2; +use sonora::config::NoiseSuppression; + +use super::buffers::CAPTURE_GAP_TOLERANCE; +use super::buffers::Frame; +use crate::audio_track::EncodedAudio; + +type Result = std::result::Result; +const BLOCK: usize = 480; +pub(super) const MAX_PROCESSING_DELAY: Duration = Duration::from_millis(/*millis*/ 500); +pub(super) const PROCESSING_LATE: &str = "voice processing fell behind"; + +struct Converter { + resampler: rubato::Async, + input: VecDeque, + output: VecDeque, + scratch: Vec, + rate: u32, + end: Option, +} + +impl Converter { + fn new(rate: u32) -> Result { + let resampler = rubato::Async::new_sinc( + 48_000.0 / f64::from(rate), + /*max_resample_ratio_relative*/ 1.0, + &rubato::SincInterpolationParameters::default(), + rate.div_ceil(100) as usize, + /*nbr_channels*/ 1, + rubato::FixedAsync::Input, + ) + .map_err(|_| "failed to create voice resampler")?; + let scratch = vec![0.0; resampler.output_frames_max()]; + Ok(Self { + resampler, + input: VecDeque::new(), + output: VecDeque::new(), + scratch, + rate, + end: None, + }) + } + + fn push(&mut self, frame: &Frame) -> Result<()> { + if frame.len > frame.samples.len() + || self.input.len() + frame.len > self.rate as usize + || self.output.len() > 48_000 + { + return Err("voice resampler backlog exceeded"); + } + self.end = + Some(frame.at + Duration::from_secs_f64(frame.len as f64 / f64::from(self.rate))); + self.input.extend(&frame.samples[..frame.len]); + while self.input.len() >= self.resampler.input_frames_next() { + let samples = self.input.make_contiguous(); + let input = InterleavedSlice::new(samples, /*channels*/ 1, samples.len()) + .map_err(|_| "invalid voice resampler input")?; + let capacity = self.scratch.len(); + let mut output = + InterleavedSlice::new_mut(&mut self.scratch, /*channels*/ 1, capacity) + .map_err(|_| "invalid voice resampler output")?; + let (consumed, produced) = self + .resampler + .process_into_buffer(&input, &mut output, /*indexing*/ None) + .map_err(|_| "failed to resample voice audio")?; + self.input.drain(..consumed); + self.output.extend(&self.scratch[..produced]); + } + Ok(()) + } + + fn next(&mut self) -> Option<(Instant, [f32; BLOCK])> { + if self.output.len() < BLOCK { + return None; + } + let delay = self.input.len() as f64 / f64::from(self.rate) + + (self.output.len() + self.resampler.output_delay()) as f64 / 48_000.0; + let end = self.end?; + let at = end + .checked_sub(Duration::from_secs_f64(delay)) + .unwrap_or(end); + let mut output = [0.0; BLOCK]; + for (out, sample) in output.iter_mut().zip(self.output.drain(..BLOCK)) { + *out = sample; + } + Some((at, output)) + } +} + +pub(super) struct Processor { + capture: Converter, + render: Converter, + apm: AudioProcessing, + encoder: opus::Encoder, + pending: Vec, + at: Instant, + cutoff: Instant, + render_delay: i64, +} + +impl Processor { + pub(super) fn new(input_rate: u32, output_rate: u32) -> Result { + Ok(Self { + capture: Converter::new(input_rate)?, + render: Converter::new(output_rate)?, + apm: AudioProcessing::builder() + .config(sonora::Config { + echo_canceller: Some(EchoCanceller::default()), + noise_suppression: Some(NoiseSuppression::default()), + gain_controller2: Some(GainController2 { + adaptive_digital: Some(AdaptiveDigital::default()), + ..Default::default() + }), + ..Default::default() + }) + .capture_config(StreamConfig::new( + /*sample_rate_hz*/ 48_000, /*num_channels*/ 1, + )) + .render_config(StreamConfig::new( + /*sample_rate_hz*/ 48_000, /*num_channels*/ 1, + )) + .build(), + encoder: opus::Encoder::new( + /*sample_rate*/ 48_000, + opus::Channels::Mono, + opus::Application::Voip, + ) + .map_err(|_| "failed to create voice encoder")?, + pending: Vec::with_capacity(960), + at: Instant::now(), + cutoff: Instant::now(), + render_delay: 0, + }) + } + + pub(super) fn reset(&mut self) -> Result<()> { + *self = Self::new(self.capture.rate, self.render.rate)?; + Ok(()) + } + + pub(super) fn set_render_rate(&mut self, rate: u32) -> Result<()> { + // Capture keeps its own rate and history across speaker-only changes. + if self.render.rate != rate { + self.render = Converter::new(rate)?; + } + self.reset_render(); + Ok(()) + } + + pub(super) fn reset_render(&mut self) { + // Speaker changes discard old references without resetting capture or APM state. + self.render.resampler.reset(); + self.render.input.clear(); + self.render.output.clear(); + self.render.end = None; + self.render_delay = 0; + } + + pub(super) fn validate_callback_timing( + &self, + input_frames: u32, + output_frames: u32, + ) -> Result<()> { + let mut delay = super::super::DEVICE_SERVICE_INTERVAL; + for (converter, frames) in [(&self.capture, input_frames), (&self.render, output_frames)] { + // Include callback and queue packing latency, plus the resampler's + // actual delay and one processing block, for both device directions. + delay += Duration::from_secs_f64( + (f64::from(frames) + super::buffers::BLOCK as f64) / f64::from(converter.rate) + + (converter.resampler.output_delay() + BLOCK) as f64 / 48_000.0, + ); + } + if delay >= MAX_PROCESSING_DELAY { + return Err("audio callback timing exceeds processing deadline"); + } + Ok(()) + } + + pub(super) fn render(&mut self, frame: &Frame) -> Result<()> { + self.render.push(frame)?; + while let Some((at, input)) = self.render.next() { + let mut output = [0.0; BLOCK]; + self.apm + .process_render_f32(&[&input], &mut [&mut output]) + .map_err(|_| "voice echo reference failed")?; + let now = Instant::now(); + self.render_delay = if at > now { + at.duration_since(now).as_millis() as i64 + } else { + -(now.duration_since(at).as_millis() as i64) + }; + } + Ok(()) + } + + pub(super) fn capture( + &mut self, + frame: &Frame, + now: impl Fn() -> Instant, + ) -> Result> { + if frame.at < self.cutoff { + return Ok(vec![]); + } + if self + .capture + .end + .is_some_and(|end| frame.at.saturating_duration_since(end) > CAPTURE_GAP_TOLERANCE) + { + // Drop resampler, APM and half-packet history without advancing the + // unmute boundary: this frame may already have waited in the queue. + let cutoff = self.cutoff; + self.reset()?; + self.cutoff = cutoff; + } + self.capture.push(frame)?; + let mut encoded = Vec::new(); + while let Some((at, input)) = self.capture.next() { + let capture_age = now().saturating_duration_since(at); + if capture_age > MAX_PROCESSING_DELAY { + return Err(PROCESSING_LATE); + } + let delay = (capture_age.as_millis() as i64 + self.render_delay).max(0); + if delay > MAX_PROCESSING_DELAY.as_millis() as i64 { + return Err(PROCESSING_LATE); + } + self.apm + .set_stream_delay_ms(delay as i32) + .map_err(|_| "invalid voice echo delay")?; + let mut output = [0.0; BLOCK]; + self.apm + .process_capture_f32(&[&input], &mut [&mut output]) + .map_err(|_| "voice capture processing failed")?; + if output.iter().any(|sample| !sample.is_finite()) { + return Err("invalid processed voice audio"); + } + if self.pending.is_empty() { + self.at = at; + } + self.pending.extend(output); + if self.pending.len() == 960 { + let mut data = vec![0; 1275]; + let len = self + .encoder + .encode_float(&self.pending, &mut data) + .map_err(|_| "failed to encode voice audio")?; + data.truncate(len); + encoded.push(EncodedAudio { data, at: self.at }); + self.pending.clear(); + } + } + Ok(encoded) + } +} + +#[cfg(test)] +#[path = "processing_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/processing_tests.rs b/codex-rs/voice-host/src/processing_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..4dffb43719f6bdbccb5953dde8a518cce0ac76b2 --- /dev/null +++ b/codex-rs/voice-host/src/processing_tests.rs @@ -0,0 +1,322 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn rendered_reference_changes_decoded_echo_capture() { + let mut processors = [ + Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 44_100).unwrap(), + Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 44_100).unwrap(), + ]; + for processor in &mut processors { + // Isolate echo cancellation from independent noise/gain adaptation. + processor.apm.apply_config(sonora::Config { + echo_canceller: Some(EchoCanceller::default()), + ..Default::default() + }); + } + let start = Instant::now(); + let signal = |seconds: f32| { + [311.0, 719.0, 1_423.0] + .into_iter() + .map(|frequency| (std::f32::consts::TAU * frequency * seconds).sin() * 0.1) + .sum::() + }; + let mut packets = [Vec::new(), Vec::new()]; + // Two seconds of deterministic samples allow AEC startup, without sleeping. + for block in 0..200 { + for offset in [0, 256] { + let mut frame = Frame { + samples: [0.0; 256], + len: 256.min(441 - offset), + at: start - Duration::from_secs(/*secs*/ 10) + + Duration::from_secs_f64((block * 441 + offset) as f64 / 44_100.0), + generation: 0, + }; + for (index, sample) in frame.samples[..frame.len].iter_mut().enumerate() { + *sample = signal((block * 441 + offset + index) as f32 / 44_100.0); + } + processors[0].render(&frame).unwrap(); + } + // Keep delay metadata identical: only consuming reference samples may + // explain the difference in the decoded capture signal. + processors[1].render_delay = processors[0].render_delay; + for offset in [0, 256] { + let mut frame = Frame { + samples: [0.0; 256], + len: 256.min(480 - offset), + at: start + Duration::from_secs_f64((block * 480 + offset) as f64 / 48_000.0), + generation: 2, + }; + for (index, sample) in frame.samples[..frame.len].iter_mut().enumerate() { + *sample = signal((block * 480 + offset + index) as f32 / 48_000.0 - 0.05); + } + for (processor, packets) in processors.iter_mut().zip(&mut packets) { + packets.extend(processor.capture(&frame, || frame.at).unwrap()); + } + } + } + let decoded = packets.map(|packets| { + let mut decoder = opus::Decoder::new(/*sample_rate*/ 48_000, opus::Channels::Mono).unwrap(); + let mut decoded = Vec::new(); + for packet in packets { + let mut samples = [0.0; 960]; + assert_eq!( + decoder + .decode_float(&packet.data, &mut samples, /*fec*/ false) + .unwrap(), + 960 + ); + assert!(samples.iter().all(|sample| sample.is_finite())); + decoded.extend(samples); + } + decoded + }); + assert_eq!(decoded[0].len(), decoded[1].len()); + assert!(decoded[0].len() > 48_000); + let energy = decoded.map(|samples| { + samples[48_000..] + .iter() + .map(|sample| sample * sample) + .sum::() + }); + assert!( + energy[1] > 1.0, + "control capture must contain the echo signal" + ); + assert!( + energy[0] < energy[1] * 0.5, + "reference/control echo energy: {energy:?}" + ); +} + +#[test] +fn real_encoder_produces_twenty_millisecond_opus_packets() { + for rate in [44_100, 48_000] { + let mut processor = Processor::new(rate, /*output_rate*/ 48_000).unwrap(); + let mut packets = Vec::new(); + for block in 0..40 { + let mut frame = Frame { + samples: [0.0; 256], + len: 256, + at: Instant::now(), + generation: 2, + }; + for (index, sample) in frame.samples.iter_mut().enumerate() { + *sample = (std::f32::consts::TAU * 440.0 * (block as usize * 256 + index) as f32 + / rate as f32) + .sin() + * 0.1; + } + packets.extend(processor.capture(&frame, || frame.at).unwrap()); + } + assert!(!packets.is_empty()); + let mut decoder = opus::Decoder::new(/*sample_rate*/ 48_000, opus::Channels::Mono).unwrap(); + let mut energy = 0.0_f32; + for packet in packets { + let mut output = [0.0; 960]; + assert_eq!( + decoder + .decode_float(&packet.data, &mut output, /*fec*/ false) + .unwrap(), + 960 + ); + assert!(output.iter().all(|sample| sample.is_finite())); + energy += output.iter().map(|sample| sample * sample).sum::(); + } + assert!( + energy > 0.01, + "decoded capture must contain signal at {rate} Hz" + ); + } +} + +#[test] +fn unmute_reset_rejects_delayed_pre_unmute_audio_and_partial_history() { + let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + let frame = Frame { + samples: [0.25; 256], + len: 256, + at: Instant::now(), + generation: 2, + }; + assert!(processor.capture(&frame, || frame.at).unwrap().is_empty()); + assert_eq!( + (0..10).find_map(|_| processor + .capture(&frame, || frame.at + Duration::from_secs(1)) + .err()), + Some("voice processing fell behind") + ); + processor.reset().unwrap(); + for _ in 0..10 { + assert!(processor.capture(&frame, || frame.at).unwrap().is_empty()); + } + assert!(processor.pending.is_empty()); + assert!(processor.capture.input.is_empty()); + assert!(processor.capture.output.is_empty()); +} + +#[test] +fn speaker_rate_change_discards_old_reference_but_keeps_capture_history() { + for rate in [48_000, 24_000, 44_100] { + let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + let mut fresh = Processor::new(/*input_rate*/ 48_000, rate).unwrap(); + let start = Instant::now(); + let mut frame = Frame { + samples: [0.75; 256], + len: 256, + at: start, + generation: 2, + }; + processor.render.push(&frame).unwrap(); + processor.render.output.push_back(0.75); + processor.capture.input.extend([0.25; 256]); + processor.pending.extend([0.25; 480]); + processor.render_delay = 123; + processor.set_render_rate(rate).unwrap(); + assert_eq!(processor.capture.input, VecDeque::from(vec![0.25; 256])); + assert_eq!(processor.pending, vec![0.25; 480]); + assert_eq!(processor.render_delay, 0); + + frame.samples.fill(0.0); + let mut actual = Vec::new(); + let mut expected = Vec::new(); + for index in 0..5 { + frame.at = start + Duration::from_secs_f64(index as f64 * 256.0 / f64::from(rate)); + processor.render.push(&frame).unwrap(); + fresh.render.push(&frame).unwrap(); + actual.extend(std::iter::from_fn(|| processor.render.next())); + expected.extend(std::iter::from_fn(|| fresh.render.next())); + } + assert!(!actual.is_empty()); + assert_eq!(actual, expected); + } +} + +#[test] +fn capture_gap_discards_old_partial_packet_and_preserves_unmute_cutoff() { + let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + let mut fresh = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + let cutoff = processor.cutoff; + let start = Instant::now(); + let mut frame = Frame { + samples: [0.25; 256], + len: 240, + at: start, + generation: 2, + }; + for index in 0..5 { + frame.at = start + Duration::from_millis(index * 5); + assert!(processor.capture(&frame, || frame.at).unwrap().is_empty()); + } + assert!(!processor.pending.is_empty()); + assert!(!processor.capture.input.is_empty()); + + let resumed = start + Duration::from_millis(/*millis*/ 100); + frame.samples.fill(0.0); + let mut packets = Vec::new(); + let mut expected = Vec::new(); + for index in 0..12 { + frame.at = resumed + Duration::from_millis(index * 5); + let emitted = processor.capture(&frame, || frame.at).unwrap(); + if index < 2 { + assert!(emitted.is_empty()); + } + packets.extend(emitted.into_iter().map(|packet| (packet.at, packet.data))); + expected.extend( + fresh + .capture(&frame, || frame.at) + .unwrap() + .into_iter() + .map(|packet| (packet.at, packet.data)), + ); + } + assert!(!packets.is_empty()); + assert_eq!(packets, expected); + assert_eq!(processor.cutoff, cutoff); +} + +#[test] +fn ordinary_capture_timestamp_jitter_keeps_partial_audio() { + for jitter_ms in [-2_i64, 0, 2] { + let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + let start = Instant::now(); + let mut frame = Frame { + samples: [0.1; 256], + len: 240, + at: start, + generation: 2, + }; + for index in 0..5 { + frame.at = start + Duration::from_millis(index * 5); + assert!(processor.capture(&frame, || frame.at).unwrap().is_empty()); + } + frame.at = start + Duration::from_millis((25 + jitter_ms) as u64); + assert_eq!(processor.capture(&frame, || frame.at).unwrap().len(), 1); + } +} + +#[test] +fn callback_gap_cannot_hide_in_a_packed_partial_block() { + let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + let mut packer = super::super::buffers::FramePacker::default(); + let queue = crossbeam_queue::ArrayQueue::new(/*capacity*/ 8); + let start = Instant::now(); + // Leave both an incomplete callback block and a processed half-packet. + for index in 0..6 { + let at = start + Duration::from_millis(index * 5); + packer.discard_capture_gap(at, /*rate*/ 48_000.0); + assert!(packer.push( + Frame { + samples: [0.25; 256], + len: 240, + at, + generation: 2 + }, + /*rate*/ 48_000.0, + &queue + )); + while let Some(frame) = queue.pop() { + assert!(processor.capture(&frame, || frame.at).unwrap().is_empty()); + } + } + assert!(!processor.pending.is_empty()); + let resumed = start + Duration::from_millis(/*millis*/ 100); + packer.discard_capture_gap(resumed, /*rate*/ 48_000.0); + assert!(packer.push( + Frame { + samples: [0.0; 256], + len: 256, + at: resumed, + generation: 2 + }, + /*rate*/ 48_000.0, + &queue + )); + let frame = queue.pop().unwrap(); + assert_eq!( + (frame.samples, frame.len, frame.at, frame.generation), + ([0.0; 256], 256, resumed, 2) + ); + assert!(processor.capture(&frame, || resumed).unwrap().is_empty()); + assert!(processor.pending.is_empty()); +} + +#[test] +fn stale_echo_reference_cannot_cancel_capture_age() { + for render_delay in [0, -750] { + let mut processor = Processor::new(/*input_rate*/ 48_000, /*output_rate*/ 48_000).unwrap(); + processor.render_delay = render_delay; + let start = Instant::now(); + let now = start + Duration::from_millis(/*millis*/ 750); + let failure = (0..8).find_map(|index| { + let frame = Frame { + samples: [0.25; 256], + len: 256, + at: start + Duration::from_secs_f64((index * 256) as f64 / 48_000.0), + generation: 2, + }; + processor.capture(&frame, || now).err() + }); + assert_eq!(failure, Some("voice processing fell behind")); + } +} diff --git a/codex-rs/voice-host/src/runtime.rs b/codex-rs/voice-host/src/runtime.rs new file mode 100644 index 0000000000000000000000000000000000000000..0c795188d45220b5ced380546eb18ba8fa81aa0c --- /dev/null +++ b/codex-rs/voice-host/src/runtime.rs @@ -0,0 +1,161 @@ +//! Initialize only the physical package's native runtime on the helper worker. +//! +//! GStreamer registers process-global callbacks. Keep every opened library loaded +//! through process exit, including partial initialization failures. This private +//! bootstrap is not a media binding API and never opens an audio device. + +use std::ffi::CString; +use std::ffi::c_char; +use std::ffi::c_void; +use std::io; +use std::mem::ManuallyDrop; +use std::path::Path; +use std::path::PathBuf; +use std::ptr; + +use codex_realtime_webrtc::RUNTIME_ENVIRONMENT; +use libloading::Library; + +type Init = unsafe extern "C" fn(*mut i32, *mut *mut *mut c_char, *mut *mut c_void) -> i32; +type LoadPlugin = unsafe extern "C" fn(*const c_char, *mut *mut c_void) -> *mut c_void; +type Unref = unsafe extern "C" fn(*mut c_void); + +pub(super) struct Runtime { + _libraries: Vec>, +} + +impl Runtime { + pub(super) fn initialize() -> io::Result { + if RUNTIME_ENVIRONMENT.iter().any(|(key, value)| { + std::env::var_os(key).as_deref() != Some(std::ffi::OsStr::new(value)) + }) { + return Err(io::Error::other("private runtime environment is required")); + } + let executable = std::env::current_exe()?.canonicalize()?; + let bin = executable.parent().ok_or_else(runtime_error)?; + let root = bin.parent().ok_or_else(runtime_error)?; + if bin.file_name().is_none_or(|name| name != "bin") + || root.file_name().is_none_or(|name| name != "voice") + || root + .parent() + .and_then(Path::file_name) + .is_none_or(|name| name != "codex-resources") + { + return Err(runtime_error()); + } + let (core, directory, prefix, suffix) = if cfg!(target_os = "macos") { + ( + "lib/libgstreamer-1.0.0.dylib", + "plugins", + "libgst", + ".dylib", + ) + } else if cfg!(all(target_os = "linux", target_env = "gnu")) { + ( + "lib/libgstreamer-1.0.so.0", + "lib/gstreamer-1.0", + "libgst", + ".so", + ) + } else if cfg!(all(windows, target_env = "msvc")) { + ("bin/gstreamer-1.0-0.dll", "bin", "gst", ".dll") + } else { + return Err(io::Error::other("unsupported native runtime platform")); + }; + let core = private_file(root, core)?; + let plugins = [ + "app", + "audioconvert", + "audioresample", + "coreelements", + "opus", + "rtp", + "rtpmanager", + ] + .map(|name| private_file(root, &format!("{directory}/{prefix}{name}{suffix}"))) + .into_iter() + .collect::>>()?; + let library = load(&core)?; + // SAFETY: These are stable GStreamer 1.x C signatures. The library stays + // loaded through process exit, and all calls occur on this one worker. + let (init, load_plugin, unref) = unsafe { + ( + *library + .get::(b"gst_init_check\0") + .map_err(|_| runtime_error())?, + *library + .get::(b"gst_plugin_load_file\0") + .map_err(|_| runtime_error())?, + *library + .get::(b"gst_object_unref\0") + .map_err(|_| runtime_error())?, + ) + }; + let mut libraries = vec![library]; + // SAFETY: GStreamer accepts null argc/argv and an omitted error output. + // Fixed child settings disable registry reads, scans, writes and forking. + if unsafe { init(ptr::null_mut(), ptr::null_mut(), ptr::null_mut()) } == 0 { + return Err(runtime_error()); + } + for plugin in plugins { + // Preload with the restricted OS search policy before GStreamer + // opens the same module, including its private dependencies on Windows. + libraries.push(load(&plugin)?); + #[cfg(unix)] + let filename = { + use std::os::unix::ffi::OsStrExt; + CString::new(plugin.as_os_str().as_bytes()) + }; + #[cfg(windows)] + let filename = CString::new(plugin.to_str().ok_or_else(runtime_error)?); + let filename = filename.map_err(|_| runtime_error())?; + // SAFETY: The NUL-terminated filename lives through the call. A + // non-null result owns a plugin reference, released exactly once. + unsafe { + let plugin = load_plugin(filename.as_ptr(), ptr::null_mut()); + if plugin.is_null() { + return Err(runtime_error()); + } + unref(plugin); + } + } + Ok(Self { + _libraries: libraries, + }) + } +} + +fn private_file(root: &Path, relative: &str) -> io::Result { + let path = root.join(relative); + if path.canonicalize()? != path || !path.is_file() { + return Err(runtime_error()); + } + Ok(path) +} + +fn load(path: &Path) -> io::Result> { + // SAFETY: Callers supply only absolute, physical paths inside the installed + // trusted package. Native code runs only in this expendable helper process. + #[cfg(unix)] + let library = unsafe { + libloading::os::unix::Library::open( + Some(path), + libloading::os::unix::RTLD_NOW | libloading::os::unix::RTLD_LOCAL, + ) + .map(Library::from) + }; + #[cfg(windows)] + let library = unsafe { + libloading::os::windows::Library::load_with_flags( + path, + libloading::os::windows::LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR + | libloading::os::windows::LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + .map(Library::from) + }; + library.map(ManuallyDrop::new).map_err(|_| runtime_error()) +} + +fn runtime_error() -> io::Error { + io::Error::other("private audio runtime initialization failed") +} diff --git a/codex-rs/voice-host/src/service_failure.rs b/codex-rs/voice-host/src/service_failure.rs new file mode 100644 index 0000000000000000000000000000000000000000..e42b23360e67e4a85ae199ec0da5e09ad8dfba45 --- /dev/null +++ b/codex-rs/voice-host/src/service_failure.rs @@ -0,0 +1,32 @@ +//! Fixed local media service failures for diagnostics without native error text. + +#[derive(Debug)] +#[cfg_attr( + not(any( + target_os = "macos", + all(target_os = "linux", target_env = "gnu"), + all(windows, target_env = "msvc") + )), + allow(dead_code) +)] +pub(crate) enum ServiceFailure { + Playout, + Render, + Capture, + Device, + Send, +} + +impl std::fmt::Display for ServiceFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Playout => "audio playout failed", + Self::Render => "audio rendering failed", + Self::Capture => "audio capture processing failed", + Self::Device => "audio device failed", + Self::Send => "voice audio send failed", + }) + } +} + +impl std::error::Error for ServiceFailure {} diff --git a/codex-rs/voice-host/src/transport.rs b/codex-rs/voice-host/src/transport.rs new file mode 100644 index 0000000000000000000000000000000000000000..e3769093a2c52de0b71aa91ebbf35484e52928ad --- /dev/null +++ b/codex-rs/voice-host/src/transport.rs @@ -0,0 +1,246 @@ +//! Owns one WebRTC peer and ordered event channel; the helper runtime bounds their lifetime. +//! Raw upstream errors and data messages are discarded; typed events use the existing sideband. +//! Candidate admission is bounded before an answer can mutate the peer or start ICE work. +//! Remotely opened channels are rejected; only the locally created event channel is used. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use futures::future::BoxFuture; +use tokio::sync::Notify; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio::time::timeout; +use webrtc::data_channel::DataChannel; +use webrtc::data_channel::DataChannelEvent; +use webrtc::peer_connection::PeerConnection; +use webrtc::peer_connection::PeerConnectionBuilder; +use webrtc::peer_connection::PeerConnectionEventHandler; +use webrtc::peer_connection::RTCIceCandidateInit; +use webrtc::peer_connection::RTCIceGatheringState; +use webrtc::peer_connection::RTCSessionDescription; + +const WAIT: Duration = Duration::from_secs(/*secs*/ 15); +const MAX_REMOTE_CANDIDATES: usize = 32; +type Result = std::result::Result; + +struct Events(Arc); + +// The upstream trait requires async-trait's boxed-future ABI. +impl PeerConnectionEventHandler for Events { + fn on_data_channel<'a, 'async_trait>( + &'a self, + channel: Arc, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + // Locally created channels do not invoke this callback. + let _ = channel.close().await; + }) + } + + fn on_ice_gathering_state_change<'a, 'async_trait>( + &'a self, + state: RTCIceGatheringState, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + if state == RTCIceGatheringState::Complete { + self.0.notify_one(); + } + }) + } +} + +#[derive(Debug, PartialEq)] +pub(crate) enum AnswerOutcome { + Ready, + TimedOut, +} + +pub(crate) struct Transport { + pub(crate) incoming: crate::incoming::Incoming, + pub(crate) audio: crate::audio_track::AudioTrack, + connection: Arc, + gathered: Arc, + observer: JoinHandle<()>, + pub(crate) ready: watch::Receiver, +} + +impl Transport { + pub(crate) async fn new() -> Result { + Self::with_runtime(Arc::new(crate::transport_runtime::VoiceRuntime::default())).await + } + + async fn with_runtime(runtime: Arc) -> Result { + let (media, audio) = crate::audio_track::AudioTrack::new()?; + let (incoming, ingress) = crate::incoming::Incoming::new(); + let gathered = Arc::new(Notify::new()); + // Upstream defaults exhaust checks after 1.4s, including checks sent before + // a TCP connection exists. Keep probing throughout our negotiation deadline. + let check_interval = Duration::from_millis(/*millis*/ 200); + let attempts = u16::try_from(WAIT.as_millis() / check_interval.as_millis()) + .map_err(|_| "invalid voice ICE attempt budget")?; + let mut settings = webrtc::peer_connection::SettingEngine::default(); + settings.set_ice_connection_attempts(Some(check_interval), Some(attempts)); + let connection: Arc = Arc::new( + PeerConnectionBuilder::new() + .with_interceptor_registry(rtc::interceptor::Registry::from(ingress)) + .with_media_engine(media) + .with_runtime(runtime) + .with_setting_engine(settings) + .with_handler(Arc::new(Events(gathered.clone()))) + .with_udp_addrs(vec!["0.0.0.0:0", "[::]:0"]) + .with_tcp_addrs(vec!["0.0.0.0:0", "[::]:0"]) + .with_data_channel_send_buffer_limit(/*bytes*/ 64 * 1024) + .with_sctp_receive_buffer_size(/*size*/ 64 * 1024) + .build() + .await + .map_err(|_| "failed to create voice peer")?, + ); + if connection.add_track(audio.track.clone()).await.is_err() { + let _ = timeout(Duration::from_secs(/*secs*/ 2), connection.close()).await; + return Err("failed to attach voice track"); + } + let channel = match connection + .create_data_channel("oai-events", /*options*/ None) + .await + { + Ok(channel) => channel, + Err(_) => { + let _ = timeout(Duration::from_secs(/*secs*/ 2), connection.close()).await; + return Err("failed to create voice event channel"); + } + }; + let (sender, ready) = watch::channel(false); + let observer = tokio::spawn(async move { + while let Some(event) = channel.poll().await { + match event { + DataChannelEvent::OnOpen => { + sender.send_replace(true); + } + DataChannelEvent::OnError + | DataChannelEvent::OnClosing + | DataChannelEvent::OnClose => break, + DataChannelEvent::OnMessage(_) + | DataChannelEvent::OnBufferedAmountLow + | DataChannelEvent::OnBufferedAmountHigh => {} + } + } + sender.send_replace(false); + }); + Ok(Self { + incoming, + audio, + connection, + gathered, + observer, + ready, + }) + } + + pub(crate) async fn offer(&self) -> Result { + timeout(WAIT, async { + let offer = self + .connection + .create_offer(/*options*/ None) + .await + .map_err(|_| "failed to create voice offer")?; + self.connection + .set_local_description(offer) + .await + .map_err(|_| "failed to set voice offer")?; + self.gathered.notified().await; + self.connection + .local_description() + .await + .map(|offer| offer.sdp) + .ok_or("missing voice offer") + }) + .await + .map_err(|_| "timed out gathering voice candidates")? + } + + pub(crate) async fn apply_answer(&self, sdp: String) -> Result { + let answer = RTCSessionDescription::answer(sdp).map_err(|_| "invalid voice answer")?; + let parsed = answer.unmarshal().map_err(|_| "invalid voice answer")?; + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + // Count all occurrences, including duplicates and other components/media sections. + // set_remote_description itself installs ICE candidates, so validate before that call. + for (index, attribute) in parsed + .media_descriptions + .iter() + .flat_map(|media| &media.attributes) + .filter(|attribute| attribute.key == "candidate") + .enumerate() + { + if index >= MAX_REMOTE_CANDIDATES { + return Err("too many voice candidates"); + } + let candidate = attribute + .value + .as_deref() + .ok_or("invalid voice candidate")?; + let parsed = rtc::ice::candidate::unmarshal_candidate(candidate) + .map_err(|_| "invalid voice candidate")?; + if parsed.component() == 1 && seen.insert(candidate) { + candidates.push(RTCIceCandidateInit { + candidate: format!("candidate:{candidate}"), + ..Default::default() + }); + } + } + timeout(WAIT, async { + self.connection + .set_remote_description(answer) + .await + .map_err(|_| "failed to apply voice answer")?; + // The async driver requires explicit candidates to start TCP dialing, + // even when they were bundled in the non-trickle answer. + for candidate in candidates { + self.connection + .add_ice_candidate(candidate) + .await + .map_err(|_| "failed to apply voice candidate")?; + } + self.ready + .clone() + .wait_for(|ready| *ready) + .await + .map(|_| ()) + .map_err(|_| "voice event channel closed") + }) + .await + .map_or(Ok(AnswerOutcome::TimedOut), |result| { + result.map(|()| AnswerOutcome::Ready) + }) + } + + pub(crate) async fn close(&mut self) -> Result<()> { + self.observer.abort(); + let _ = (&mut self.observer).await; + timeout(Duration::from_secs(/*secs*/ 2), self.connection.close()) + .await + .map_err(|_| "timed out closing voice peer")? + .map_err(|_| "failed to close voice peer") + } +} + +impl Drop for Transport { + fn drop(&mut self) { + self.observer.abort(); + // The helper owns the runtime: destroying it cancels all remaining peer tasks. + } +} + +#[cfg(test)] +#[path = "transport_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/transport_network_tests.rs b/codex-rs/voice-host/src/transport_network_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..ae7682ecdca5bfff48a03c5ba07cd61a551ad375 --- /dev/null +++ b/codex-rs/voice-host/src/transport_network_tests.rs @@ -0,0 +1,134 @@ +//! Exercises real ICE recovery after initial packet loss and slow TCP connection setup. +//! All other runtime behavior, including inbound TCP limits, remains unchanged. + +use std::future::Future; +use std::io; +use std::io::IoSliceMut; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::OnceLock; +use std::task::Context; +use std::task::Poll; +use std::time::Duration; +use std::time::Instant; + +use futures::future::BoxFuture; +use webrtc::runtime::AsyncInterval; +use webrtc::runtime::AsyncTcpListener; +use webrtc::runtime::AsyncTcpStream; +use webrtc::runtime::AsyncUdpSocket; +use webrtc::runtime::JoinHandle; +use webrtc::runtime::RecvMeta; +use webrtc::runtime::Runtime; +use webrtc::runtime::Transmit; + +use crate::transport_runtime::VoiceRuntime; + +const INITIAL_OUTAGE: Duration = Duration::from_secs(/*secs*/ 3); + +#[derive(Debug, Default)] +pub(super) struct DelayedNetwork { + inner: VoiceRuntime, + first_send: Arc>, +} + +impl Runtime for DelayedNetwork { + fn spawn(&self, future: BoxFuture<'static, ()>) -> Box { + self.inner.spawn(future) + } + + fn spawn_reactor( + &self, + reactor_pool_size: usize, + future: BoxFuture<'static, ()>, + ) -> Box { + self.inner.spawn_reactor(reactor_pool_size, future) + } + + fn wrap_udp_socket(&self, socket: std::net::UdpSocket) -> io::Result> { + Ok(Arc::new(LossyUdp { + inner: self.inner.wrap_udp_socket(socket)?, + first_send: self.first_send.clone(), + })) + } + + fn wrap_tcp_listener( + &self, + listener: std::net::TcpListener, + ) -> io::Result> { + self.inner.wrap_tcp_listener(listener) + } + + fn connect_tcp( + &self, + remote_addr: SocketAddr, + ) -> BoxFuture<'_, io::Result>> { + Box::pin(async move { + self.inner.sleep(INITIAL_OUTAGE).await; + self.inner.connect_tcp(remote_addr).await + }) + } + + fn resolve_host<'a>(&'a self, host: &'a str) -> BoxFuture<'a, io::Result>> { + self.inner.resolve_host(host) + } + + fn sleep(&self, duration: Duration) -> BoxFuture<'static, ()> { + self.inner.sleep(duration) + } + + fn interval(&self, period: Duration) -> Box { + self.inner.interval(period) + } + + fn block_on(&self, future: Pin + '_>>) { + self.inner.block_on(future); + } + + fn yield_now(&self) -> BoxFuture<'static, ()> { + self.inner.yield_now() + } + + fn name(&self) -> &'static str { + self.inner.name() + } +} + +#[derive(Debug)] +struct LossyUdp { + inner: Arc, + first_send: Arc>, +} + +impl AsyncUdpSocket for LossyUdp { + fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } + + fn max_gso_segments(&self) -> usize { + self.inner.max_gso_segments() + } + + fn max_gro_segments(&self) -> usize { + self.inner.max_gro_segments() + } + + fn poll_send(&self, cx: &mut Context<'_>, transmit: &Transmit<'_>) -> Poll> { + if self.first_send.get_or_init(Instant::now).elapsed() < INITIAL_OUTAGE { + // Report success but discard the datagram, like loss after leaving the socket. + Poll::Ready(Ok(transmit.contents.len())) + } else { + self.inner.poll_send(cx, transmit) + } + } + + fn poll_recv( + &self, + cx: &mut Context<'_>, + bufs: &mut [IoSliceMut<'_>], + meta: &mut [RecvMeta], + ) -> Poll> { + self.inner.poll_recv(cx, bufs, meta) + } +} diff --git a/codex-rs/voice-host/src/transport_runtime.rs b/codex-rs/voice-host/src/transport_runtime.rs new file mode 100644 index 0000000000000000000000000000000000000000..65e15c8efe6371030c5edf5ef11697a9acd813b3 --- /dev/null +++ b/codex-rs/voice-host/src/transport_runtime.rs @@ -0,0 +1,119 @@ +//! Bounds retained inbound TCP streams across one peer's listeners before authentication. +//! Weak references reuse slots after stream ownership ends; other Tokio I/O stays unchanged. + +use std::future::Future; +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::Weak; +use std::time::Duration; + +use futures::future::BoxFuture; +use webrtc::runtime::AsyncInterval; +use webrtc::runtime::AsyncTcpListener; +use webrtc::runtime::AsyncTcpStream; +use webrtc::runtime::AsyncUdpSocket; +use webrtc::runtime::JoinHandle; +use webrtc::runtime::Runtime; +use webrtc::runtime::TokioRuntime; + +const MAX_INBOUND_STREAMS: usize = 32; +type AcceptedStreams = Arc>>>; + +#[derive(Debug, Default)] +pub(crate) struct VoiceRuntime { + accepted: AcceptedStreams, +} + +impl Runtime for VoiceRuntime { + fn spawn(&self, future: BoxFuture<'static, ()>) -> Box { + TokioRuntime.spawn(future) + } + + fn spawn_reactor( + &self, + reactor_pool_size: usize, + future: BoxFuture<'static, ()>, + ) -> Box { + TokioRuntime.spawn_reactor(reactor_pool_size, future) + } + + fn wrap_udp_socket(&self, socket: std::net::UdpSocket) -> io::Result> { + TokioRuntime.wrap_udp_socket(socket) + } + + fn wrap_tcp_listener( + &self, + listener: std::net::TcpListener, + ) -> io::Result> { + Ok(Arc::new(LimitedListener { + inner: TokioRuntime.wrap_tcp_listener(listener)?, + accepted: self.accepted.clone(), + })) + } + + fn connect_tcp( + &self, + remote_addr: SocketAddr, + ) -> BoxFuture<'_, io::Result>> { + TokioRuntime.connect_tcp(remote_addr) + } + + fn resolve_host<'a>(&'a self, host: &'a str) -> BoxFuture<'a, io::Result>> { + TokioRuntime.resolve_host(host) + } + + fn sleep(&self, duration: Duration) -> BoxFuture<'static, ()> { + TokioRuntime.sleep(duration) + } + + fn interval(&self, period: Duration) -> Box { + TokioRuntime.interval(period) + } + + fn block_on(&self, future: Pin + '_>>) { + TokioRuntime.block_on(future); + } + + fn yield_now(&self) -> BoxFuture<'static, ()> { + TokioRuntime.yield_now() + } + + fn name(&self) -> &'static str { + TokioRuntime.name() + } +} + +#[derive(Debug)] +struct LimitedListener { + inner: Arc, + accepted: AcceptedStreams, +} + +impl AsyncTcpListener for LimitedListener { + fn accept(&self) -> BoxFuture<'_, io::Result<(Arc, SocketAddr)>> { + Box::pin(async move { + let (stream, address) = self.inner.accept().await?; + let mut accepted = self + .accepted + .lock() + .map_err(|_| io::Error::other("voice TCP admission unavailable"))?; + accepted.retain(|stream| stream.strong_count() > 0); + if accepted.len() >= MAX_INBOUND_STREAMS { + return Err(io::Error::other("voice TCP connection limit reached")); + } + accepted.push(Arc::downgrade(&stream)); + Ok((stream, address)) + }) + } + + fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } +} + +#[cfg(test)] +#[path = "transport_runtime_tests.rs"] +mod tests; diff --git a/codex-rs/voice-host/src/transport_runtime_tests.rs b/codex-rs/voice-host/src/transport_runtime_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..d643c9a0a297b2455e3ac9a54807289f5d0dd95a --- /dev/null +++ b/codex-rs/voice-host/src/transport_runtime_tests.rs @@ -0,0 +1,55 @@ +//! Exercise admission and slot reuse with real TCP sockets across multiple listeners. + +use super::*; +use pretty_assertions::assert_eq; +use tokio::time::timeout; + +#[tokio::test] +async fn caps_inbound_streams_and_reuses_slots_across_listeners() { + timeout(Duration::from_secs(/*secs*/ 10), async { + let runtime = VoiceRuntime::default(); + let first = runtime + .wrap_tcp_listener(std::net::TcpListener::bind("127.0.0.1:0").unwrap()) + .unwrap(); + let second = runtime + .wrap_tcp_listener(std::net::TcpListener::bind("127.0.0.1:0").unwrap()) + .unwrap(); + let mut clients = Vec::new(); + let mut streams = Vec::new(); + for _ in 0..MAX_INBOUND_STREAMS { + clients.push( + runtime + .connect_tcp(first.local_addr().unwrap()) + .await + .unwrap(), + ); + streams.push(first.accept().await.unwrap().0); + } + // A second listener shares the budget; rejecting a connection closes its socket. + let rejected = runtime + .connect_tcp(second.local_addr().unwrap()) + .await + .unwrap(); + assert_eq!( + second.accept().await.unwrap_err().to_string(), + "voice TCP connection limit reached" + ); + assert_eq!(rejected.read(&mut [0]).await.unwrap(), 0); + assert_eq!(runtime.accepted.lock().unwrap().len(), MAX_INBOUND_STREAMS); + + drop(streams.pop()); + let replacement = runtime + .connect_tcp(second.local_addr().unwrap()) + .await + .unwrap(); + let (accepted, _) = second.accept().await.unwrap(); + replacement.write_all(b"x").await.unwrap(); + let mut byte = [0]; + assert_eq!(accepted.read(&mut byte).await.unwrap(), 1); + assert_eq!(byte, *b"x"); + assert_eq!(runtime.accepted.lock().unwrap().len(), MAX_INBOUND_STREAMS); + drop((clients, streams, accepted, replacement)); + }) + .await + .unwrap(); +} diff --git a/codex-rs/voice-host/src/transport_tests.rs b/codex-rs/voice-host/src/transport_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..3646959820ce44f78474ec2a5a000e1420c9488c --- /dev/null +++ b/codex-rs/voice-host/src/transport_tests.rs @@ -0,0 +1,210 @@ +#[path = "transport_network_tests.rs"] +mod network; + +use super::*; +use pretty_assertions::assert_eq; +use tokio::sync::mpsc; +use webrtc::data_channel::RTCDataChannelState; + +struct RemoteEvents(mpsc::Sender>, Arc); + +impl PeerConnectionEventHandler for RemoteEvents { + fn on_data_channel<'a, 'async_trait>( + &'a self, + channel: Arc, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + self.0.try_send(channel).unwrap(); + }) + } + + fn on_ice_gathering_state_change<'a, 'async_trait>( + &'a self, + state: RTCIceGatheringState, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + if state == RTCIceGatheringState::Complete { + self.1.notify_one(); + } + }) + } +} + +#[tokio::test] +async fn negotiates_real_ordered_channel_over_udp_and_tcp() { + check_negotiation(Arc::new(crate::transport_runtime::VoiceRuntime::default())).await; +} + +#[tokio::test] +async fn negotiates_after_early_packet_loss_and_slow_tcp_connect() { + check_negotiation(Arc::new(network::DelayedNetwork::default())).await; +} + +async fn check_negotiation(runtime: Arc) { + for tcp in [false, true] { + timeout(Duration::from_secs(/*secs*/ 30), async { + let (sender, mut channels) = mpsc::channel(/*buffer*/ 1); + let gathered = Arc::new(Notify::new()); + let mut settings = webrtc::peer_connection::SettingEngine::default(); + settings.set_lite(/*lite*/ true); + let (media, mut remote_audio) = crate::audio_track::AudioTrack::new().unwrap(); + let builder = PeerConnectionBuilder::new() + .with_media_engine(media) + .with_setting_engine(settings) + .with_handler(Arc::new(RemoteEvents(sender, gathered.clone()))); + let remote = if tcp { + builder.with_tcp_addrs(vec!["0.0.0.0:0"]) + } else { + builder.with_udp_addrs(vec!["0.0.0.0:0"]) + } + .build() + .await + .unwrap(); + remote.add_track(remote_audio.track.clone()).await.unwrap(); + let mut local = Transport::with_runtime(runtime.clone()).await.unwrap(); + let offer = RTCSessionDescription::offer(local.offer().await.unwrap()).unwrap(); + remote.set_remote_description(offer).await.unwrap(); + let answer = remote.create_answer(/*options*/ None).await.unwrap(); + remote.set_local_description(answer).await.unwrap(); + gathered.notified().await; + let mut answer = remote.local_description().await.unwrap().sdp; + let candidates: Vec<_> = answer + .lines() + .filter(|line| line.starts_with("a=candidate:")) + .map(str::to_owned) + .collect(); + // Normal generated UDP/TCP answers still connect at the admission boundary. + assert!(!candidates.is_empty() && candidates.len() <= MAX_REMOTE_CANDIDATES); + for _ in candidates.len()..MAX_REMOTE_CANDIDATES { + answer.push_str(&format!("{}\r\n", candidates[0])); + } + let outcome = local + .apply_answer(answer) + .await + .unwrap_or_else(|error| panic!("tcp={tcp}: {error}")); + assert_eq!(outcome, super::AnswerOutcome::Ready); + let channel = channels.recv().await.unwrap(); + assert_eq!( + ( + channel.label().await.unwrap(), + channel.ordered().await.unwrap() + ), + ("oai-events".into(), true) + ); + let unexpected = remote + .create_data_channel("unexpected", /*options*/ None) + .await + .unwrap(); + loop { + match unexpected.poll().await { + Some(DataChannelEvent::OnClose) => break, + Some( + DataChannelEvent::OnOpen + | DataChannelEvent::OnClosing + | DataChannelEvent::OnBufferedAmountLow + | DataChannelEvent::OnBufferedAmountHigh, + ) => {} + event => panic!("unexpected channel did not close: {event:?}"), + } + } + assert_eq!( + (channel.ready_state().await.unwrap(), *local.ready.borrow()), + (RTCDataChannelState::Open, true) + ); + channel.send_text("synthetic sideband event").await.unwrap(); + local.incoming.set_suppressed(/*suppressed*/ false).unwrap(); + let sent = std::time::Instant::now(); + remote_audio + .send(crate::audio_track::EncodedAudio { + data: vec![0xf8, 0xff, 0xfe], + at: sent, + }) + .await + .unwrap(); + let received = loop { + if let Some(packet) = local.incoming.take().unwrap() { + break packet; + } + tokio::time::sleep(Duration::from_millis(/*millis*/ 5)).await; + }; + assert!(received.at >= sent); + assert_eq!( + &received.as_ref()[received.as_ref().len() - 3..], + &[0xf8, 0xff, 0xfe] + ); + local.close().await.unwrap(); + assert!(local.ready.has_changed().is_err()); + remote.close().await.unwrap(); + }) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn rejects_excess_or_invalid_candidates_before_mutating_peer() { + let mut peer = Transport::new().await.unwrap(); + let offer = peer.offer().await.unwrap(); + let header = offer + .lines() + .filter(|line| !line.starts_with("a=candidate:")) + .map(|line| format!("{line}\r\n")) + .collect::(); + for transport in ["udp", "tcp"] { + let mut answer = header.clone(); + for index in 0..=MAX_REMOTE_CANDIDATES { + let suffix = if transport == "tcp" { + " tcptype passive" + } else { + "" + }; + answer.push_str(&format!( + "a=candidate:{index} 1 {transport} 1 192.0.2.1 9 typ host{suffix}\r\n" + )); + } + assert_eq!( + peer.apply_answer(answer).await, + Err("too many voice candidates") + ); + assert!(peer.connection.remote_description().await.is_none()); + } + // The budget is global, before duplicate/component/inactive-media filtering. + let candidate = "a=candidate:duplicate 2 udp 1 192.0.2.1 9 typ host\r\n"; + let answer = format!( + "{header}{}m=application 0 UDP/DTLS/SCTP webrtc-datachannel\r\na=inactive\r\n{}", + candidate.repeat(16), + candidate.repeat(17), + ); + assert_eq!( + peer.apply_answer(answer).await, + Err("too many voice candidates") + ); + assert!(peer.connection.remote_description().await.is_none()); + let answer = format!("{header}{candidate}a=candidate:synthetic-secret-invalid\r\n"); + assert_eq!( + peer.apply_answer(answer).await, + Err("invalid voice candidate") + ); + assert!(peer.connection.remote_description().await.is_none()); + peer.close().await.unwrap(); +} + +#[tokio::test] +async fn invalid_answer_is_redacted_and_peer_can_close() { + let mut peer = Transport::new().await.unwrap(); + peer.offer().await.unwrap(); + assert_eq!( + peer.apply_answer("synthetic-secret-invalid-sdp".into()) + .await, + Err("invalid voice answer") + ); + peer.close().await.unwrap(); +} diff --git a/codex-rs/voice-host/tests/installed_client.rs b/codex-rs/voice-host/tests/installed_client.rs new file mode 100644 index 0000000000000000000000000000000000000000..13531ab0092b7fb03cad0b418fbe2f3e427e81f2 --- /dev/null +++ b/codex-rs/voice-host/tests/installed_client.rs @@ -0,0 +1,363 @@ +//! Exercise the production client against copied helper packages and startup libraries. + +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use codex_install_context::CodexPackageLayout; +use codex_install_context::InstallContext; +use codex_realtime_webrtc::SessionDescription; +use codex_realtime_webrtc::VoiceHost; +use codex_utils_cargo_bin::cargo_bin; +use futures::future::BoxFuture; +use pretty_assertions::assert_eq; +use tokio::process::Command; +use tokio::sync::Notify; +use tokio::sync::mpsc; +use tokio::time::timeout; +use webrtc::data_channel::DataChannel; +use webrtc::peer_connection::MediaEngine; +use webrtc::peer_connection::PeerConnection; +use webrtc::peer_connection::PeerConnectionBuilder; +use webrtc::peer_connection::PeerConnectionEventHandler; +use webrtc::peer_connection::RTCIceConnectionState; +use webrtc::peer_connection::RTCIceGatheringState; +use webrtc::peer_connection::RTCPeerConnectionState; +use webrtc::peer_connection::RTCSessionDescription; +use webrtc::peer_connection::SettingEngine; + +const DEADLINE: Duration = Duration::from_secs(/*secs*/ 10); + +async fn build_commit() -> Result { + let output = timeout( + DEADLINE, + Command::new(cargo_bin("codex-voice-host")?) + .arg("--build-commit") + .kill_on_drop(true) + .output(), + ) + .await??; + assert!(output.status.success()); + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +fn install_startup_libraries(runtime: &Path) -> Result<()> { + if !cfg!(any( + target_os = "macos", + all(target_os = "linux", target_env = "gnu"), + all(windows, target_env = "msvc") + )) { + return Ok(()); + } + let source = if codex_utils_cargo_bin::runfiles_available() { + let resource = format!( + "../../third_party/voice/native_link_{}_{}/runtime.json", + std::env::consts::OS, + std::env::consts::ARCH + ); + codex_utils_cargo_bin::find_resource!(resource)? + .parent() + .context("runtime parent")? + .to_owned() + } else { + std::env::var_os("CODEX_TEST_VOICE_RUNTIME") + .map(PathBuf::from) + .context("prepared runtime required for installed helper tests")? + }; + let libraries = if cfg!(windows) { "bin" } else { "lib" }; + let destination = runtime.join(libraries); + fs::create_dir_all(&destination)?; + for entry in fs::read_dir(source.join(libraries))? { + let entry = entry?; + if entry.path().is_file() { + fs::copy(entry.path(), destination.join(entry.file_name()))?; + } + } + Ok(()) +} + +fn install_helper(root: &Path) -> Result { + let bin = root.join("bin"); + let helper_dir = root.join("codex-resources/voice/bin"); + fs::create_dir_all(&bin)?; + fs::create_dir_all(&helper_dir)?; + fs::write(root.join("codex-package.json"), "{}")?; + let app = bin.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&app, [])?; + let source = cargo_bin("codex-voice-host")?; + let helper = helper_dir.join(source.file_name().context("helper binary file name")?); + fs::copy(&source, &helper)?; + install_startup_libraries(helper_dir.parent().context("helper runtime directory")?)?; + InstallContext::from_exe( + /*is_macos*/ cfg!(target_os = "macos"), + Some(&app), + /*method_override*/ None, + ) + .package_layout + .context("package layout") +} + +#[tokio::test] +async fn installed_client_rejects_mixed_builds_and_missing_helper() -> Result<()> { + let directory = tempfile::Builder::new() + .prefix("voice package ") + .tempdir()?; + let package = install_helper(directory.path())?; + let bin = directory.path().join("bin"); + let source = cargo_bin("codex-voice-host")?; + let helper = directory + .path() + .join("codex-resources/voice/bin") + .join(source.file_name().context("helper binary file name")?); + VoiceHost::connect(&package, &build_commit().await?) + .await? + .close() + .await?; + assert!(VoiceHost::connect(&package, "wrong-build").await.is_err()); + // Startup libraries permit handshakes; the missing runtime receipt prevents readiness. + let host = VoiceHost::connect(&package, &build_commit().await?).await?; + assert!(host.initialize_runtime().await.is_err()); + // The same executable elsewhere in the package must not become a fallback. + fs::rename(&helper, bin.join(source.file_name().unwrap()))?; + assert!( + VoiceHost::connect(&package, &build_commit().await?) + .await + .is_err() + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&source, &helper)?; + assert!( + VoiceHost::connect(&package, &build_commit().await?) + .await + .is_err() + ); + } + Ok(()) +} + +// Linux permits raw-byte filenames; macOS filesystems reject this name themselves. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn installed_client_accepts_non_utf8_package_path() -> Result<()> { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let directory = tempfile::tempdir()?; + let root = directory + .path() + .join(OsString::from_vec(b"voice-\xff".to_vec())); + let bin = root.join("bin"); + let helper_dir = root.join("codex-resources/voice/bin"); + fs::create_dir_all(&bin)?; + fs::create_dir_all(&helper_dir)?; + fs::write(root.join("codex-package.json"), "{}")?; + let app = bin.join("codex"); + fs::write(&app, [])?; + let source = cargo_bin("codex-voice-host")?; + fs::copy(&source, helper_dir.join(source.file_name().unwrap()))?; + install_startup_libraries(helper_dir.parent().unwrap())?; + let package = InstallContext::from_exe( + /*is_macos*/ cfg!(target_os = "macos"), + Some(&app), + /*method_override*/ None, + ) + .package_layout + .context("package layout")?; + VoiceHost::connect(&package, &build_commit().await?) + .await? + .close() + .await +} + +enum RemoteState { + Ice(RTCIceConnectionState), + Peer(RTCPeerConnectionState), + Channel, +} + +struct RemoteEvents { + channels: mpsc::Sender>, + gathered: Arc, + diagnostics: mpsc::Sender<(Duration, RemoteState)>, + started: Instant, +} + +impl RemoteEvents { + fn record(&self, state: RemoteState) { + // Keep only the first 32 events; diagnostics must never wait on the connection driver. + let _ = self.diagnostics.try_send((self.started.elapsed(), state)); + } +} + +impl PeerConnectionEventHandler for RemoteEvents { + fn on_ice_connection_state_change<'a, 'async_trait>( + &'a self, + state: RTCIceConnectionState, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { self.record(RemoteState::Ice(state)) }) + } + + fn on_connection_state_change<'a, 'async_trait>( + &'a self, + state: RTCPeerConnectionState, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { self.record(RemoteState::Peer(state)) }) + } + + fn on_data_channel<'a, 'async_trait>( + &'a self, + channel: Arc, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + self.record(RemoteState::Channel); + assert!( + self.channels.try_send(channel).is_ok(), + "receiver must accept the event channel" + ); + }) + } + + fn on_ice_gathering_state_change<'a, 'async_trait>( + &'a self, + state: RTCIceGatheringState, + ) -> BoxFuture<'async_trait, ()> + where + 'a: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + if state == RTCIceGatheringState::Complete { + self.gathered.notify_one(); + } + }) + } +} + +#[tokio::test] +async fn installed_client_negotiates_and_closes_over_udp_and_tcp() -> Result<()> { + let directory = tempfile::Builder::new() + .prefix("voice package ") + .tempdir()?; + let package = install_helper(directory.path())?; + let commit = build_commit().await?; + for tcp in [false, true] { + let started = Instant::now(); + let (diagnostics, mut states) = mpsc::channel(/*buffer*/ 32); + let negotiation = timeout(Duration::from_secs(/*secs*/ 30), async { + let (sender, mut channels) = mpsc::channel(/*buffer*/ 1); + let gathered = Arc::new(Notify::new()); + let mut settings = SettingEngine::default(); + settings.set_lite(/*lite*/ true); + let mut media = MediaEngine::default(); + media.register_default_codecs()?; + let builder = PeerConnectionBuilder::new() + .with_media_engine(media) + .with_setting_engine(settings) + .with_handler(Arc::new(RemoteEvents { + channels: sender, + gathered: gathered.clone(), + diagnostics, + started, + })); + let remote = if tcp { + builder.with_tcp_addrs(vec!["0.0.0.0:0"]) + } else { + builder.with_udp_addrs(vec!["0.0.0.0:0"]) + } + .build() + .await?; + let result: Result<()> = async { + let host = VoiceHost::connect(&package, &commit).await?; + let (host, offer) = host.start_transport().await?; + remote + .set_remote_description(RTCSessionDescription::offer(offer.into_sdp())?) + .await?; + let answer = remote.create_answer(/*options*/ None).await?; + remote.set_local_description(answer).await?; + gathered.notified().await; + let answer = remote.local_description().await.context("local answer")?; + let host = host + .apply_answer( + SessionDescription::try_from(answer.sdp).map_err(anyhow::Error::msg)?, + ) + .await + .with_context(|| format!("tcp={tcp}: apply voice answer"))?; + let channel = channels.recv().await.context("event channel")?; + assert_eq!( + (channel.label().await?, channel.ordered().await?), + ("oai-events".into(), true) + ); + // This checks the Closed acknowledgement and successful child exit as well. + host.close().await + } + .await; + remote.close().await?; + result + }); + let mut heartbeat = tokio::time::interval(Duration::from_millis(/*millis*/ 100)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut ticks = 0_u32; + let mut last_poll = started; + let mut max_gap = Duration::ZERO; + let mut max_gap_at = Duration::ZERO; + let result = { + tokio::pin!(negotiation); + loop { + let outcome = tokio::select! { + result = &mut negotiation => Some(result), + _ = heartbeat.tick() => { + ticks += 1; + None + } + }; + // Include the final polling gap even when negotiation or its timeout wins. + let now = Instant::now(); + let gap = now.duration_since(last_poll); + if gap > max_gap { + max_gap = gap; + max_gap_at = now.duration_since(started); + } + last_poll = now; + if let Some(result) = outcome { + break result; + } + } + }; + result + .with_context(|| format!("tcp={tcp}: helper negotiation timed out")) + .and_then(std::convert::identity) + .with_context(|| { + let events: Vec<_> = std::iter::from_fn(|| states.try_recv().ok()) + .map(|(elapsed, state)| match state { + RemoteState::Ice(state) => format!("{elapsed:?}: ICE {state}"), + RemoteState::Peer(state) => format!("{elapsed:?}: peer {state}"), + RemoteState::Channel => format!("{elapsed:?}: channel arrived"), + }) + .collect(); + format!( + "tcp={tcp}: remote connection events (first 32): {events:?}; \ + test runtime heartbeat: ticks={ticks}, max polling gap={max_gap:?} \ + observed at {max_gap_at:?}" + ) + })?; + } + Ok(()) +} diff --git a/codex-rs/voice-host/tests/lifecycle.rs b/codex-rs/voice-host/tests/lifecycle.rs new file mode 100644 index 0000000000000000000000000000000000000000..a7c18fcdb9d17819dac0f54f62b9a5d3b95d5636 --- /dev/null +++ b/codex-rs/voice-host/tests/lifecycle.rs @@ -0,0 +1,138 @@ +//! Exercise the real helper protocol and bounded process I/O. + +use std::process::Stdio; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use codex_realtime_webrtc::Message; +use codex_realtime_webrtc::decode_frame; +use codex_realtime_webrtc::encode_frame; +use codex_utils_cargo_bin::cargo_bin; +use pretty_assertions::assert_eq; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::process::Child; +use tokio::process::Command; +use tokio::time::timeout; + +const DEADLINE: Duration = Duration::from_secs(/*secs*/ 10); + +fn spawn() -> Result { + Ok(Command::new(cargo_bin("codex-voice-host")?) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn()?) +} + +async fn build_commit() -> Result { + let output = timeout( + DEADLINE, + Command::new(cargo_bin("codex-voice-host")?) + .arg("--build-commit") + .kill_on_drop(true) + .output(), + ) + .await??; + assert!(output.status.success()); + Ok(String::from_utf8(output.stdout)?.trim().to_owned()) +} + +async fn handshake(child: &mut Child) -> Result<()> { + child + .stdin + .as_mut() + .context("stdin")? + .write_all(&encode_frame(&Message::Hello { + protocol: 1, + build_commit: build_commit().await?, + })?) + .await?; + let expected = encode_frame(&Message::Ready {})?; + let mut reply = vec![0; expected.len()]; + timeout( + DEADLINE, + child + .stdout + .as_mut() + .context("stdout")? + .read_exact(&mut reply), + ) + .await??; + assert_eq!(reply, expected); + Ok(()) +} + +#[tokio::test] +async fn closes_after_acknowledgement_and_on_parent_pipe_loss() -> Result<()> { + for explicit_close in [true, false] { + let mut child = spawn()?; + handshake(&mut child).await?; + if explicit_close { + child + .stdin + .as_mut() + .unwrap() + .write_all(&encode_frame(&Message::Close {})?) + .await?; + } + drop(child.stdin.take()); + let output = timeout(DEADLINE, child.wait_with_output()).await??; + assert!(output.status.success()); + assert_eq!( + output.stdout, + if explicit_close { + encode_frame(&Message::Closed {})? + } else { + vec![] + } + ); + assert!(output.stderr.is_empty()); + } + Ok(()) +} + +#[tokio::test] +async fn parent_pipe_loss_cancels_transport_startup() -> Result<()> { + let mut child = spawn()?; + handshake(&mut child).await?; + child + .stdin + .take() + .unwrap() + .write_all(&encode_frame(&Message::StartTransport {})?) + .await?; + let output = timeout(Duration::from_secs(/*secs*/ 5), child.wait_with_output()).await??; + assert!(output.stderr.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn rejects_invalid_input_without_echoing_it() -> Result<()> { + assert!(decode_frame(b"\0\0\0\x16{\"type\":\"close\",\"x\":0}").is_err()); + let mut invalid_json = 22_u32.to_be_bytes().to_vec(); + invalid_json.extend_from_slice(b"sensitive-invalid-json"); + for frame in [ + u32::MAX.to_be_bytes().to_vec(), + vec![0, 0], + invalid_json, + encode_frame(&Message::Hello { + protocol: 1, + build_commit: "wrong-build".into(), + })?, + encode_frame(&Message::Hello { + protocol: 99, + build_commit: build_commit().await?, + })?, + encode_frame(&Message::Close {})?, + ] { + let mut child = spawn()?; + child.stdin.take().unwrap().write_all(&frame).await?; + let output = timeout(DEADLINE, child.wait_with_output()).await??; + assert!(!output.status.success()); + assert_eq!((output.stdout, output.stderr), (vec![], vec![])); + } + Ok(()) +} diff --git a/codex-rs/voice-host/tests/packaged_runtime.rs b/codex-rs/voice-host/tests/packaged_runtime.rs new file mode 100644 index 0000000000000000000000000000000000000000..91c9daa526d71c870cbca2ed169843c446c9c32f --- /dev/null +++ b/codex-rs/voice-host/tests/packaged_runtime.rs @@ -0,0 +1,88 @@ +//! Exercise native initialization with real prepared libraries in a relocated package. + +use std::fs; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use codex_install_context::InstallContext; +use codex_realtime_webrtc::VoiceHost; +use codex_utils_cargo_bin::cargo_bin; +use tokio::process::Command; +use tokio::time::timeout; + +const DEADLINE: Duration = Duration::from_secs(/*secs*/ 10); + +#[tokio::test] +#[ignore = "requires CODEX_TEST_VOICE_RUNTIME containing real prepared native libraries"] +async fn relocated_runtime_initializes_closes_and_rejects_duplicate_initialization() -> Result<()> { + let source = std::env::var_os("CODEX_TEST_VOICE_RUNTIME") + .context("set CODEX_TEST_VOICE_RUNTIME to a matching prepared runtime")?; + let source = fs::canonicalize(source)?; + ensure!( + source.join("runtime.json").is_file(), + "prepared runtime receipt missing" + ); + let directory = tempfile::Builder::new() + .prefix("voice native package ") + .tempdir()?; + let root = directory.path().join("staging"); + let runtime = root.join("codex-resources/voice"); + fs::create_dir_all(&runtime)?; + let mut pending = vec![(source, runtime.clone())]; + while let Some((source, destination)) = pending.pop() { + for entry in fs::read_dir(source)? { + let entry = entry?; + let target = destination.join(entry.file_name()); + let kind = entry.file_type()?; + if kind.is_dir() { + fs::create_dir(&target)?; + pending.push((entry.path(), target)); + } else { + ensure!(kind.is_file(), "runtime inputs must be physical files"); + fs::copy(entry.path(), target)?; + } + } + } + let helper_source = cargo_bin("codex-voice-host")?; + let name = helper_source.file_name().context("helper filename")?; + fs::create_dir_all(runtime.join("bin"))?; + fs::copy(&helper_source, runtime.join("bin").join(name))?; + fs::create_dir(root.join("bin"))?; + let app_name = if cfg!(windows) { "codex.exe" } else { "codex" }; + fs::write(root.join("bin").join(app_name), [])?; + fs::write(root.join("codex-package.json"), "{}")?; + let moved = directory.path().join("relocated package"); + fs::rename(root, &moved)?; + let package = InstallContext::from_exe( + /*is_macos*/ cfg!(target_os = "macos"), + Some(&moved.join("bin").join(app_name)), + /*method_override*/ None, + ) + .package_layout + .context("package layout")?; + let output = timeout( + DEADLINE, + Command::new(&helper_source) + .arg("--build-commit") + .kill_on_drop(true) + .output(), + ) + .await??; + ensure!(output.status.success(), "helper build identity failed"); + let build_commit = String::from_utf8(output.stdout)?.trim().to_owned(); + VoiceHost::connect(&package, &build_commit) + .await? + .initialize_runtime() + .await? + .close() + .await?; + + let host = VoiceHost::connect(&package, &build_commit) + .await? + .initialize_runtime() + .await?; + assert!(host.initialize_runtime().await.is_err()); + Ok(()) +}