diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 707fd9d430..11138fdc8a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2055,7 +2055,9 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "notify", + "pretty_assertions", "regex-lite", + "reqwest", "serde_json", "shlex", "tempfile", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 05362b204e..4f6cfaa8ed 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -209,13 +209,26 @@ v2_enum_from_core!( ); #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] #[ts(export_to = "v2/")] pub enum ConfigLayerName { - Mdm, - System, - SessionFlags, - User, + /// Managed preferences layer delivered by MDM (macOS only). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Mdm { domain: String, key: String }, + /// Managed config layer from a file (usually `managed_config.toml`). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + System { file: AbsolutePathBuf }, + /// Session-layer overrides supplied via `-c`/`--config`. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + SessionFlags { override_keys: Vec }, + /// User config layer from a file (usually `config.toml`). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + User { file: AbsolutePathBuf }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] @@ -289,7 +302,6 @@ pub struct Config { #[ts(export_to = "v2/")] pub struct ConfigLayerMetadata { pub name: ConfigLayerName, - pub source: String, pub version: String, } @@ -298,7 +310,6 @@ pub struct ConfigLayerMetadata { #[ts(export_to = "v2/")] pub struct ConfigLayer { pub name: ConfigLayerName, - pub source: String, pub version: String, pub config: JsonValue, } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 199d2f88e6..d0cd78a99b 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -42,6 +43,7 @@ model = "gpt-user" sandbox_mode = "workspace-write" "#, )?; + let user_file = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -65,12 +67,19 @@ sandbox_mode = "workspace-write" assert_eq!(config.model.as_deref(), Some("gpt-user")); assert_eq!( origins.get("model").expect("origin").name, - ConfigLayerName::User + ConfigLayerName::User { + file: user_file.clone(), + } ); let layers = layers.expect("layers present"); assert_eq!(layers.len(), 2); - assert_eq!(layers[0].name, ConfigLayerName::SessionFlags); - assert_eq!(layers[1].name, ConfigLayerName::User); + assert_eq!( + layers[0].name, + ConfigLayerName::SessionFlags { + override_keys: Vec::new(), + } + ); + assert_eq!(layers[1].name, ConfigLayerName::User { file: user_file }); Ok(()) } @@ -88,6 +97,7 @@ web_search = true view_image = false "#, )?; + let user_file = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -118,17 +128,26 @@ view_image = false ); assert_eq!( origins.get("tools.web_search").expect("origin").name, - ConfigLayerName::User + ConfigLayerName::User { + file: user_file.clone(), + } ); assert_eq!( origins.get("tools.view_image").expect("origin").name, - ConfigLayerName::User + ConfigLayerName::User { + file: user_file.clone(), + } ); let layers = layers.expect("layers present"); assert_eq!(layers.len(), 2); - assert_eq!(layers[0].name, ConfigLayerName::SessionFlags); - assert_eq!(layers[1].name, ConfigLayerName::User); + assert_eq!( + layers[0].name, + ConfigLayerName::SessionFlags { + override_keys: Vec::new(), + } + ); + assert_eq!(layers[1].name, ConfigLayerName::User { file: user_file }); Ok(()) } @@ -153,8 +172,10 @@ network_access = true serde_json::json!(user_dir) ), )?; + let user_file = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; let managed_path = codex_home.path().join("managed_config.toml"); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?; std::fs::write( &managed_path, format!( @@ -197,19 +218,25 @@ writable_roots = [{}] assert_eq!(config.model.as_deref(), Some("gpt-system")); assert_eq!( origins.get("model").expect("origin").name, - ConfigLayerName::System + ConfigLayerName::System { + file: managed_file.clone(), + } ); assert_eq!(config.approval_policy, Some(AskForApproval::Never)); assert_eq!( origins.get("approval_policy").expect("origin").name, - ConfigLayerName::System + ConfigLayerName::System { + file: managed_file.clone(), + } ); assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); assert_eq!( origins.get("sandbox_mode").expect("origin").name, - ConfigLayerName::User + ConfigLayerName::User { + file: user_file.clone(), + } ); let sandbox = config @@ -222,7 +249,9 @@ writable_roots = [{}] .get("sandbox_workspace_write.writable_roots.0") .expect("origin") .name, - ConfigLayerName::System + ConfigLayerName::System { + file: managed_file.clone(), + } ); assert!(sandbox.network_access); @@ -231,14 +260,24 @@ writable_roots = [{}] .get("sandbox_workspace_write.network_access") .expect("origin") .name, - ConfigLayerName::User + ConfigLayerName::User { + file: user_file.clone(), + } ); let layers = layers.expect("layers present"); assert_eq!(layers.len(), 3); - assert_eq!(layers[0].name, ConfigLayerName::System); - assert_eq!(layers[1].name, ConfigLayerName::SessionFlags); - assert_eq!(layers[2].name, ConfigLayerName::User); + assert_eq!( + layers[0].name, + ConfigLayerName::System { file: managed_file } + ); + assert_eq!( + layers[1].name, + ConfigLayerName::SessionFlags { + override_keys: Vec::new(), + } + ); + assert_eq!(layers[2].name, ConfigLayerName::User { file: user_file }); Ok(()) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9a21f7bfe2..5f0e322dae 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2476,6 +2476,7 @@ async fn try_run_turn( let mut needs_follow_up = false; let mut last_agent_message: Option = None; let mut active_item: Option = None; + let mut should_emit_turn_diff = false; let receiving_span = info_span!("receiving_stream"); let outcome: CodexResult = loop { let handle_responses = info_span!( @@ -2551,14 +2552,7 @@ async fn try_run_turn( } => { sess.update_token_usage_info(&turn_context, token_usage.as_ref()) .await; - let unified_diff = { - let mut tracker = turn_diff_tracker.lock().await; - tracker.get_unified_diff() - }; - if let Ok(Some(unified_diff)) = unified_diff { - let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); - sess.send_event(&turn_context, msg).await; - } + should_emit_turn_diff = true; break Ok(TurnRunResult { needs_follow_up, @@ -2632,7 +2626,18 @@ async fn try_run_turn( } }; - drain_in_flight(&mut in_flight, sess, turn_context).await?; + drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; + + if should_emit_turn_diff { + let unified_diff = { + let mut tracker = turn_diff_tracker.lock().await; + tracker.get_unified_diff() + }; + if let Ok(Some(unified_diff)) = unified_diff { + let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); + sess.clone().send_event(&turn_context, msg).await; + } + } outcome } diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 393d637f31..ed561af61e 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -499,10 +499,10 @@ fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a Tom fn override_message(layer: &ConfigLayerName) -> String { match layer { - ConfigLayerName::Mdm => "Overridden by managed policy (mdm)".to_string(), - ConfigLayerName::System => "Overridden by managed config (system)".to_string(), - ConfigLayerName::SessionFlags => "Overridden by session flags".to_string(), - ConfigLayerName::User => "Overridden by user config".to_string(), + ConfigLayerName::Mdm { .. } => "Overridden by managed policy (mdm)".to_string(), + ConfigLayerName::System { .. } => "Overridden by managed config (system)".to_string(), + ConfigLayerName::SessionFlags { .. } => "Overridden by session flags".to_string(), + ConfigLayerName::User { .. } => "Overridden by user config".to_string(), } } @@ -576,6 +576,7 @@ mod tests { use super::*; use anyhow::Result; use codex_app_server_protocol::AskForApproval; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -677,16 +678,19 @@ remote_compaction = true #[tokio::test] async fn read_includes_origins_and_layers() { let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); + let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file"); let managed_path = tmp.path().join("managed_config.toml"); std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); let service = ConfigService::with_overrides( tmp.path().to_path_buf(), vec![], LoaderOverrides { - managed_config_path: Some(managed_path), + managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, }, @@ -707,12 +711,25 @@ remote_compaction = true .get("approval_policy") .expect("origin") .name, - ConfigLayerName::System + ConfigLayerName::System { + file: managed_file.clone(), + } ); let layers = response.layers.expect("layers present"); - assert_eq!(layers.first().unwrap().name, ConfigLayerName::System); - assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags); - assert_eq!(layers.last().unwrap().name, ConfigLayerName::User); + assert_eq!( + layers.first().unwrap().name, + ConfigLayerName::System { file: managed_file } + ); + assert_eq!( + layers.get(1).unwrap().name, + ConfigLayerName::SessionFlags { + override_keys: Vec::new(), + } + ); + assert_eq!( + layers.last().unwrap().name, + ConfigLayerName::User { file: user_file } + ); } #[tokio::test] @@ -726,12 +743,13 @@ remote_compaction = true let managed_path = tmp.path().join("managed_config.toml"); std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); let service = ConfigService::with_overrides( tmp.path().to_path_buf(), vec![], LoaderOverrides { - managed_config_path: Some(managed_path), + managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, }, @@ -764,7 +782,7 @@ remote_compaction = true .get("approval_policy") .expect("origin") .name, - ConfigLayerName::System + ConfigLayerName::System { file: managed_file } ); assert_eq!(result.status, WriteStatus::Ok); assert!(result.overridden_metadata.is_none()); @@ -773,7 +791,8 @@ remote_compaction = true #[tokio::test] async fn version_conflict_rejected() { let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); let service = ConfigService::new(tmp.path().to_path_buf(), vec![]); let error = service @@ -830,7 +849,7 @@ remote_compaction = true tmp.path().to_path_buf(), vec![], LoaderOverrides { - managed_config_path: Some(managed_path), + managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, }, @@ -860,10 +879,13 @@ remote_compaction = true #[tokio::test] async fn read_reports_managed_overrides_user_and_session_flags() { let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + let user_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&user_path, "model = \"user\"").unwrap(); + let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file"); let managed_path = tmp.path().join("managed_config.toml"); std::fs::write(&managed_path, "model = \"system\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); let cli_overrides = vec![( "model".to_string(), @@ -874,7 +896,7 @@ remote_compaction = true tmp.path().to_path_buf(), cli_overrides, LoaderOverrides { - managed_config_path: Some(managed_path), + managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, }, @@ -890,12 +912,25 @@ remote_compaction = true assert_eq!(response.config.model.as_deref(), Some("system")); assert_eq!( response.origins.get("model").expect("origin").name, - ConfigLayerName::System + ConfigLayerName::System { + file: managed_file.clone(), + } ); let layers = response.layers.expect("layers"); - assert_eq!(layers.first().unwrap().name, ConfigLayerName::System); - assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags); - assert_eq!(layers.get(2).unwrap().name, ConfigLayerName::User); + assert_eq!( + layers.first().unwrap().name, + ConfigLayerName::System { file: managed_file } + ); + assert_eq!( + layers.get(1).unwrap().name, + ConfigLayerName::SessionFlags { + override_keys: vec!["model".to_string()], + } + ); + assert_eq!( + layers.get(2).unwrap().name, + ConfigLayerName::User { file: user_file } + ); } #[tokio::test] @@ -905,12 +940,13 @@ remote_compaction = true let managed_path = tmp.path().join("managed_config.toml"); std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file"); let service = ConfigService::with_overrides( tmp.path().to_path_buf(), vec![], LoaderOverrides { - managed_config_path: Some(managed_path), + managed_config_path: Some(managed_path.clone()), #[cfg(target_os = "macos")] managed_preferences_base64: None, }, @@ -929,7 +965,10 @@ remote_compaction = true assert_eq!(result.status, WriteStatus::OkOverridden); let overridden = result.overridden_metadata.expect("overridden metadata"); - assert_eq!(overridden.overriding_layer.name, ConfigLayerName::System); + assert_eq!( + overridden.overriding_layer.name, + ConfigLayerName::System { file: managed_file } + ); assert_eq!(overridden.effective_value, serde_json::json!("never")); } diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md index da427c4544..9df656951c 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/core/src/config_loader/README.md @@ -16,7 +16,7 @@ Exported from `codex_core::config_loader`: - `origins() -> HashMap` - `layers_high_to_low() -> Vec` - `with_user_config(user_config) -> ConfigLayerStack` -- `ConfigLayerEntry` (one layer’s `{name, source, config, version}`) +- `ConfigLayerEntry` (one layer’s `{name, config, version}`; `name` carries source metadata) - `LoaderOverrides` (test/override hooks for managed config sources) - `merge_toml_values(base, overlay)` (public helper used elsewhere) @@ -61,4 +61,3 @@ Implementation is split by concern: - `merge.rs`: recursive TOML merge. - `fingerprint.rs`: stable per-layer hashing and per-key origins traversal. - `macos.rs`: managed preferences integration (macOS only). - diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index c7c522008d..89b225d6e6 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -10,9 +10,9 @@ mod tests; use crate::config::CONFIG_TOML_FILE; use codex_app_server_protocol::ConfigLayerName; +use codex_utils_absolute_path::AbsolutePathBuf; use std::io; use std::path::Path; -use std::path::PathBuf; use toml::Value as TomlValue; pub use merge::merge_toml_values; @@ -20,8 +20,8 @@ pub use state::ConfigLayerEntry; pub use state::ConfigLayerStack; pub use state::LoaderOverrides; -const SESSION_FLAGS_SOURCE: &str = "--config"; -const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64"; +const MDM_PREFERENCES_DOMAIN: &str = "com.openai.codex"; +const MDM_PREFERENCES_KEY: &str = "config_toml_base64"; /// Configuration layering pipeline (top overrides bottom): /// @@ -51,24 +51,38 @@ pub async fn load_config_layers_state( .unwrap_or_else(|| layer_io::managed_config_default_path(codex_home)); let layers = layer_io::load_config_layers_internal(codex_home, overrides).await?; - let cli_overrides = overrides::build_cli_overrides_layer(cli_overrides); + let session_override_keys = cli_overrides.iter().map(|(key, _)| key.clone()).collect(); + let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides); + let user_file = AbsolutePathBuf::from_absolute_path(codex_home.join(CONFIG_TOML_FILE))?; + + let system = match layers.managed_config { + Some(cfg) => { + let system_file = AbsolutePathBuf::from_absolute_path(managed_config_path.clone())?; + Some(ConfigLayerEntry::new( + ConfigLayerName::System { file: system_file }, + cfg, + )) + } + None => None, + }; Ok(ConfigLayerStack { - user: ConfigLayerEntry::new( - ConfigLayerName::User, - codex_home.join(CONFIG_TOML_FILE), - layers.base, - ), + user: ConfigLayerEntry::new(ConfigLayerName::User { file: user_file }, layers.base), session_flags: ConfigLayerEntry::new( - ConfigLayerName::SessionFlags, - PathBuf::from(SESSION_FLAGS_SOURCE), - cli_overrides, + ConfigLayerName::SessionFlags { + override_keys: session_override_keys, + }, + cli_overrides_layer, ), - system: layers.managed_config.map(|cfg| { - ConfigLayerEntry::new(ConfigLayerName::System, managed_config_path.clone(), cfg) + system, + mdm: layers.managed_preferences.map(|cfg| { + ConfigLayerEntry::new( + ConfigLayerName::Mdm { + domain: MDM_PREFERENCES_DOMAIN.to_string(), + key: MDM_PREFERENCES_KEY.to_string(), + }, + cfg, + ) }), - mdm: layers - .managed_preferences - .map(|cfg| ConfigLayerEntry::new(ConfigLayerName::Mdm, PathBuf::from(MDM_SOURCE), cfg)), }) } diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index e23cc243ce..b0c56af2e5 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -19,17 +19,15 @@ pub struct LoaderOverrides { #[derive(Debug, Clone)] pub struct ConfigLayerEntry { pub name: ConfigLayerName, - pub source: PathBuf, pub config: TomlValue, pub version: String, } impl ConfigLayerEntry { - pub fn new(name: ConfigLayerName, source: PathBuf, config: TomlValue) -> Self { + pub fn new(name: ConfigLayerName, config: TomlValue) -> Self { let version = version_for_toml(&config); Self { name, - source, config, version, } @@ -38,7 +36,6 @@ impl ConfigLayerEntry { pub fn metadata(&self) -> ConfigLayerMetadata { ConfigLayerMetadata { name: self.name.clone(), - source: self.source.display().to_string(), version: self.version.clone(), } } @@ -46,7 +43,6 @@ impl ConfigLayerEntry { pub fn as_layer(&self) -> ConfigLayer { ConfigLayer { name: self.name.clone(), - source: self.source.display().to_string(), version: self.version.clone(), config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null), } @@ -64,11 +60,7 @@ pub struct ConfigLayerStack { impl ConfigLayerStack { pub fn with_user_config(&self, user_config: TomlValue) -> Self { Self { - user: ConfigLayerEntry::new( - self.user.name.clone(), - self.user.source.clone(), - user_config, - ), + user: ConfigLayerEntry::new(self.user.name.clone(), user_config), session_flags: self.session_flags.clone(), system: self.system.clone(), mdm: self.mdm.clone(), diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index b383079f44..844216e203 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -35,7 +36,7 @@ const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; #[derive(Debug)] pub struct ModelsManager { // todo(aibrahim) merge available_models and model family creation into one struct - available_models: RwLock>, + local_models: Vec, remote_models: RwLock>, auth_manager: Arc, etag: RwLock>, @@ -49,7 +50,7 @@ impl ModelsManager { pub fn new(auth_manager: Arc) -> Self { let codex_home = auth_manager.codex_home().to_path_buf(); Self { - available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), + local_models: builtin_model_presets(auth_manager.get_auth_mode()), remote_models: RwLock::new(Vec::new()), auth_manager, etag: RwLock::new(None), @@ -64,7 +65,7 @@ impl ModelsManager { pub fn with_provider(auth_manager: Arc, provider: ModelProviderInfo) -> Self { let codex_home = auth_manager.codex_home().to_path_buf(); Self { - available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), + local_models: builtin_model_presets(auth_manager.get_auth_mode()), remote_models: RwLock::new(Vec::new()), auth_manager, etag: RwLock::new(None), @@ -107,13 +108,13 @@ impl ModelsManager { if let Err(err) = self.refresh_available_models(config).await { error!("failed to refresh available models: {err}"); } - self.available_models.read().await.clone() + let remote_models = self.remote_models.read().await.clone(); + self.build_available_models(remote_models) } pub fn try_list_models(&self) -> Result, TryLockError> { - self.available_models - .try_read() - .map(|models| models.clone()) + let remote_models = self.remote_models.try_read()?.clone(); + Ok(self.build_available_models(remote_models)) } fn find_family_for_model(slug: &str) -> ModelFamily { @@ -123,8 +124,8 @@ impl ModelsManager { /// Look up the requested model family while applying remote metadata overrides. pub async fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { Self::find_family_for_model(model) - .with_config_overrides(config) .with_remote_overrides(self.remote_models.read().await.clone()) + .with_config_overrides(config) } pub async fn get_model(&self, model: &Option, config: &Config) -> String { @@ -136,11 +137,10 @@ impl ModelsManager { } // if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model let auth_mode = self.auth_manager.get_auth_mode(); + let remote_models = self.remote_models.read().await.clone(); if auth_mode == Some(AuthMode::ChatGPT) && self - .available_models - .read() - .await + .build_available_models(remote_models) .iter() .any(|m| m.model == CODEX_AUTO_BALANCED_MODEL) { @@ -163,7 +163,6 @@ impl ModelsManager { /// Replace the cached remote models and rebuild the derived presets list. async fn apply_remote_models(&self, models: Vec) { *self.remote_models.write().await = models; - self.build_available_models().await; } /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. @@ -203,22 +202,55 @@ impl ModelsManager { } } - /// Convert remote model metadata into picker-ready presets, marking defaults. - async fn build_available_models(&self) { - let mut available_models = self.remote_models.read().await.clone(); - available_models.sort_by(|a, b| a.priority.cmp(&b.priority)); - let mut model_presets: Vec = available_models - .into_iter() - .map(Into::into) - .filter(|preset: &ModelPreset| preset.show_in_picker) - .collect(); - if let Some(default) = model_presets.first_mut() { + /// Merge remote model metadata into picker-ready presets, preserving existing entries. + fn build_available_models(&self, mut remote_models: Vec) -> Vec { + remote_models.sort_by(|a, b| a.priority.cmp(&b.priority)); + + let remote_presets: Vec = remote_models.into_iter().map(Into::into).collect(); + let existing_presets = self.local_models.clone(); + let mut merged_presets = Self::merge_presets(remote_presets, existing_presets); + merged_presets = Self::filter_visible_models(merged_presets); + + let has_default = merged_presets.iter().any(|preset| preset.is_default); + if let Some(default) = merged_presets.first_mut() + && !has_default + { default.is_default = true; } - { - let mut available_models_guard = self.available_models.write().await; - *available_models_guard = model_presets; + + merged_presets + } + + fn filter_visible_models(models: Vec) -> Vec { + models + .into_iter() + .filter(|model| model.show_in_picker) + .collect() + } + + fn merge_presets( + remote_presets: Vec, + existing_presets: Vec, + ) -> Vec { + if remote_presets.is_empty() { + return existing_presets; } + + let remote_slugs: HashSet<&str> = remote_presets + .iter() + .map(|preset| preset.model.as_str()) + .collect(); + + let mut merged_presets = remote_presets.clone(); + for mut preset in existing_presets { + if remote_slugs.contains(preset.model.as_str()) { + continue; + } + preset.is_default = false; + merged_presets.push(preset); + } + + merged_presets } fn cache_path(&self) -> PathBuf { @@ -261,11 +293,21 @@ mod tests { use crate::model_provider_info::WireApi; use codex_protocol::openai_models::ModelsResponse; use core_test_support::responses::mount_models_once; + use pretty_assertions::assert_eq; use serde_json::json; use tempfile::tempdir; use wiremock::MockServer; fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { + remote_model_with_visibility(slug, display, priority, "list") + } + + fn remote_model_with_visibility( + slug: &str, + display: &str, + priority: i32, + visibility: &str, + ) -> ModelInfo { serde_json::from_value(json!({ "slug": slug, "display_name": display, @@ -273,7 +315,7 @@ mod tests { "default_reasoning_level": "medium", "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}], "shell_type": "shell_command", - "visibility": "list", + "visibility": visibility, "minimal_client_version": [0, 1, 0], "supported_in_api": true, "priority": priority, @@ -347,14 +389,23 @@ mod tests { assert_eq!(cached_remote, remote_models); let available = manager.list_models(&config).await; - assert_eq!(available.len(), 2); - assert_eq!(available[0].model, "priority-high"); + let high_idx = available + .iter() + .position(|model| model.model == "priority-high") + .expect("priority-high should be listed"); + let low_idx = available + .iter() + .position(|model| model.model == "priority-low") + .expect("priority-low should be listed"); assert!( - available[0].is_default, + high_idx < low_idx, + "higher priority should be listed before lower priority" + ); + assert!( + available[high_idx].is_default, "highest priority should be default" ); - assert_eq!(available[1].model, "priority-low"); - assert!(!available[1].is_default); + assert!(!available[low_idx].is_default); assert_eq!( models_mock.requests().len(), 1, @@ -493,4 +544,94 @@ mod tests { "stale cache refresh should fetch /models once" ); } + + #[tokio::test] + async fn refresh_available_models_drops_removed_remote_models() { + let server = MockServer::start().await; + let initial_models = vec![remote_model("remote-old", "Remote Old", 1)]; + let initial_mock = mount_models_once( + &server, + ModelsResponse { + models: initial_models, + etag: String::new(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let provider = provider_for(server.uri()); + let mut manager = ModelsManager::with_provider(auth_manager, provider); + manager.cache_ttl = Duration::ZERO; + + manager + .refresh_available_models(&config) + .await + .expect("initial refresh succeeds"); + + server.reset().await; + let refreshed_models = vec![remote_model("remote-new", "Remote New", 1)]; + let refreshed_mock = mount_models_once( + &server, + ModelsResponse { + models: refreshed_models, + etag: String::new(), + }, + ) + .await; + + manager + .refresh_available_models(&config) + .await + .expect("second refresh succeeds"); + + let available = manager + .try_list_models() + .expect("models should be available"); + assert!( + available.iter().any(|preset| preset.model == "remote-new"), + "new remote model should be listed" + ); + assert!( + !available.iter().any(|preset| preset.model == "remote-old"), + "removed remote model should not be listed" + ); + assert_eq!( + initial_mock.requests().len(), + 1, + "initial refresh should only hit /models once" + ); + assert_eq!( + refreshed_mock.requests().len(), + 1, + "second refresh should only hit /models once" + ); + } + + #[test] + fn build_available_models_picks_default_after_hiding_hidden_models() { + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let provider = provider_for("http://example.test".to_string()); + let mut manager = ModelsManager::with_provider(auth_manager, provider); + manager.local_models = Vec::new(); + + let hidden_model = remote_model_with_visibility("hidden", "Hidden", 0, "hide"); + let visible_model = remote_model_with_visibility("visible", "Visible", 1, "list"); + + let mut expected = ModelPreset::from(visible_model.clone()); + expected.is_default = true; + + let available = manager.build_available_models(vec![hidden_model, visible_model]); + + assert_eq!(available, vec![expected]); + } } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index e1c9a65252..1373fdf248 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -16,7 +16,6 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use futures::Future; -use tracing::Instrument; use tracing::debug; use tracing::instrument; @@ -59,16 +58,10 @@ pub(crate) async fn handle_output_item_done( .await; let cancellation_token = ctx.cancellation_token.child_token(); - let tool_runtime = ctx.tool_runtime.clone(); - let tool_future: InFlightFuture<'static> = Box::pin( - async move { - let response_input = tool_runtime - .handle_tool_call(call, cancellation_token) - .await?; - Ok(response_input) - } - .in_current_span(), + ctx.tool_runtime + .clone() + .handle_tool_call(call, cancellation_token), ); output.needs_follow_up = true; diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index c1ef916d7a..9536eedafe 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -70,9 +70,11 @@ pub fn format_exec_output_for_model_freeform( // round to 1 decimal place let duration_seconds = ((exec_output.duration.as_secs_f32()) * 10.0).round() / 10.0; - let total_lines = exec_output.aggregated_output.text.lines().count(); + let content = build_content_with_timeout(exec_output); - let formatted_output = truncate_text(&exec_output.aggregated_output.text, truncation_policy); + let total_lines = content.lines().count(); + + let formatted_output = truncate_text(&content, truncation_policy); let mut sections = Vec::new(); @@ -92,21 +94,21 @@ pub fn format_exec_output_str( exec_output: &ExecToolCallOutput, truncation_policy: TruncationPolicy, ) -> String { - let ExecToolCallOutput { - aggregated_output, .. - } = exec_output; - - let content = aggregated_output.text.as_str(); - - let body = if exec_output.timed_out { - format!( - "command timed out after {} milliseconds\n{content}", - exec_output.duration.as_millis() - ) - } else { - content.to_string() - }; + let content = build_content_with_timeout(exec_output); // Truncate for model consumption before serialization. - formatted_truncate_text(&body, truncation_policy) + formatted_truncate_text(&content, truncation_policy) +} + +/// Extracts exec output content and prepends a timeout message if the command timed out. +fn build_content_with_timeout(exec_output: &ExecToolCallOutput) -> String { + if exec_output.timed_out { + format!( + "command timed out after {} milliseconds\n{}", + exec_output.duration.as_millis(), + exec_output.aggregated_output.text + ) + } else { + exec_output.aggregated_output.text.clone() + } } diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index feef518e57..d146507e5c 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -47,7 +47,7 @@ impl ToolCallRuntime { #[instrument(skip_all, fields(call = ?call))] pub(crate) fn handle_tool_call( - &self, + self, call: ToolCall, cancellation_token: CancellationToken, ) -> impl std::future::Future> { diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 95ea788b4c..0f0abbb3cb 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -22,3 +22,7 @@ tokio = { workspace = true, features = ["time"] } walkdir = { workspace = true } wiremock = { workspace = true } shlex = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +reqwest = { workspace = true } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 61bac30cb1..dc0cc53f46 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; use assert_cmd::cargo::cargo_bin; pub mod responses; +pub mod streaming_sse; pub mod test_codex; pub mod test_codex_exec; diff --git a/codex-rs/core/tests/common/streaming_sse.rs b/codex-rs/core/tests/common/streaming_sse.rs new file mode 100644 index 0000000000..4f1b3673b0 --- /dev/null +++ b/codex-rs/core/tests/common/streaming_sse.rs @@ -0,0 +1,680 @@ +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::sync::Mutex as TokioMutex; +use tokio::sync::oneshot; + +/// Streaming SSE chunk payload gated by a per-chunk signal. +#[derive(Debug)] +pub struct StreamingSseChunk { + pub gate: Option>, + pub body: String, +} + +/// Minimal streaming SSE server for tests that need gated per-chunk delivery. +pub struct StreamingSseServer { + uri: String, + shutdown: oneshot::Sender<()>, + task: tokio::task::JoinHandle<()>, +} + +impl StreamingSseServer { + pub fn uri(&self) -> &str { + &self.uri + } + + pub async fn shutdown(self) { + let _ = self.shutdown.send(()); + let _ = self.task.await; + } +} + +/// Starts a lightweight HTTP server that supports: +/// - GET /v1/models -> empty models response +/// - POST /v1/responses -> SSE stream gated per-chunk, served in order +/// +/// Returns the server handle and a list of receivers that fire when each +/// response stream finishes sending its final chunk. +pub async fn start_streaming_sse_server( + responses: Vec>, +) -> (StreamingSseServer, Vec>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind streaming SSE server"); + let addr = listener.local_addr().expect("streaming SSE server address"); + let uri = format!("http://{addr}"); + + let mut completion_senders = Vec::with_capacity(responses.len()); + let mut completion_receivers = Vec::with_capacity(responses.len()); + for _ in 0..responses.len() { + let (tx, rx) = oneshot::channel(); + completion_senders.push(tx); + completion_receivers.push(rx); + } + + let state = Arc::new(TokioMutex::new(StreamingSseState { + responses: VecDeque::from(responses), + completions: VecDeque::from(completion_senders), + })); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accept_res = listener.accept() => { + let (mut stream, _) = accept_res.expect("accept streaming SSE connection"); + let state = Arc::clone(&state); + tokio::spawn(async move { + let (request, body_prefix) = read_http_request(&mut stream).await; + let Some((method, path)) = parse_request_line(&request) else { + let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await; + return; + }; + + if method == "GET" && path == "/v1/models" { + if drain_request_body(&mut stream, &request, body_prefix) + .await + .is_err() + { + let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await; + return; + } + let body = serde_json::json!({ + "data": [], + "object": "list" + }) + .to_string(); + let _ = write_http_response(&mut stream, 200, &body, "application/json").await; + return; + } + + if method == "POST" && path == "/v1/responses" { + if drain_request_body(&mut stream, &request, body_prefix) + .await + .is_err() + { + let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await; + return; + } + let Some((chunks, completion)) = take_next_stream(&state).await else { + let _ = write_http_response(&mut stream, 500, "no responses queued", "text/plain").await; + return; + }; + + if write_sse_headers(&mut stream).await.is_err() { + return; + } + + for chunk in chunks { + if let Some(gate) = chunk.gate + && gate.await.is_err() { + return; + } + if stream.write_all(chunk.body.as_bytes()).await.is_err() { + return; + } + let _ = stream.flush().await; + } + + let _ = completion.send(unix_ms_now()); + let _ = stream.shutdown().await; + return; + } + + let _ = write_http_response(&mut stream, 404, "not found", "text/plain").await; + }); + } + } + } + }); + + ( + StreamingSseServer { + uri, + shutdown: shutdown_tx, + task, + }, + completion_receivers, + ) +} + +struct StreamingSseState { + responses: VecDeque>, + completions: VecDeque>, +} + +async fn take_next_stream( + state: &TokioMutex, +) -> Option<(Vec, oneshot::Sender)> { + let mut guard = state.lock().await; + let chunks = guard.responses.pop_front()?; + let completion = guard.completions.pop_front()?; + Some((chunks, completion)) +} + +async fn read_http_request(stream: &mut tokio::net::TcpStream) -> (String, Vec) { + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + let read = stream.read(&mut scratch).await.unwrap_or(0); + if read == 0 { + break; + } + buf.extend_from_slice(&scratch[..read]); + if let Some(end) = header_terminator_index(&buf) { + let header_end = end + 4; + let header = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + let rest = buf[header_end..].to_vec(); + return (header, rest); + } + } + (String::from_utf8_lossy(&buf).into_owned(), Vec::new()) +} + +fn parse_request_line(request: &str) -> Option<(&str, &str)> { + let line = request.lines().next()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?; + let path = parts.next()?; + Some((method, path)) +} + +fn header_terminator_index(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n") +} + +fn content_length(headers: &str) -> Option { + headers.lines().skip(1).find_map(|line| { + let mut parts = line.splitn(2, ':'); + let name = parts.next()?.trim(); + let value = parts.next()?.trim(); + if name.eq_ignore_ascii_case("content-length") { + value.parse::().ok() + } else { + None + } + }) +} + +async fn drain_request_body( + stream: &mut tokio::net::TcpStream, + headers: &str, + mut body_prefix: Vec, +) -> std::io::Result<()> { + let Some(content_len) = content_length(headers) else { + return Ok(()); + }; + + if body_prefix.len() > content_len { + body_prefix.truncate(content_len); + } + + let remaining = content_len.saturating_sub(body_prefix.len()); + if remaining == 0 { + return Ok(()); + } + + let mut rest = vec![0u8; remaining]; + stream.read_exact(&mut rest).await?; + Ok(()) +} + +async fn write_sse_headers(stream: &mut tokio::net::TcpStream) -> std::io::Result<()> { + let headers = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\nconnection: close\r\n\r\n"; + stream.write_all(headers.as_bytes()).await +} + +async fn write_http_response( + stream: &mut tokio::net::TcpStream, + status: i64, + body: &str, + content_type: &str, +) -> std::io::Result<()> { + let body_len = body.len(); + let headers = format!( + "HTTP/1.1 {status} OK\r\ncontent-type: {content_type}\r\ncontent-length: {body_len}\r\nconnection: close\r\n\r\n" + ); + stream.write_all(headers.as_bytes()).await?; + stream.write_all(body.as_bytes()).await?; + stream.shutdown().await +} + +fn unix_ms_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use reqwest::StatusCode; + use tokio::net::TcpStream; + use tokio::time::Duration; + use tokio::time::timeout; + + fn split_response(response: &str) -> (&str, &str) { + response + .split_once("\r\n\r\n") + .expect("response missing header separator") + } + + fn status_code(headers: &str) -> u16 { + let line = headers.lines().next().expect("status line"); + let mut parts = line.split_whitespace(); + let _ = parts.next(); + let status = parts.next().expect("status code"); + status.parse().expect("parse status code") + } + + fn header_value<'a>(headers: &'a str, name: &str) -> Option<&'a str> { + headers.lines().skip(1).find_map(|line| { + let mut parts = line.splitn(2, ':'); + let key = parts.next()?.trim(); + let value = parts.next()?.trim(); + if key.eq_ignore_ascii_case(name) { + Some(value) + } else { + None + } + }) + } + + async fn connect(uri: &str) -> TcpStream { + let addr = uri.strip_prefix("http://").expect("uri should be http"); + TcpStream::connect(addr) + .await + .expect("connect to streaming SSE server") + } + + async fn read_to_end(stream: &mut TcpStream) -> String { + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await.expect("read response"); + String::from_utf8_lossy(&buf).into_owned() + } + + async fn read_until(stream: &mut TcpStream, needle: &str) -> (String, String) { + let mut buf = Vec::new(); + let mut scratch = [0u8; 256]; + let needle_bytes = needle.as_bytes(); + loop { + let read = stream.read(&mut scratch).await.expect("read response"); + if read == 0 { + break; + } + buf.extend_from_slice(&scratch[..read]); + if let Some(pos) = buf + .windows(needle_bytes.len()) + .position(|window| window == needle_bytes) + { + let end = pos + needle_bytes.len(); + let headers = String::from_utf8_lossy(&buf[..end]).into_owned(); + let remainder = String::from_utf8_lossy(&buf[end..]).into_owned(); + return (headers, remainder); + } + } + (String::from_utf8_lossy(&buf).into_owned(), String::new()) + } + + async fn send_request(stream: &mut TcpStream, request: &str) { + stream + .write_all(request.as_bytes()) + .await + .expect("write request"); + } + + #[tokio::test] + async fn get_models_returns_empty_list() { + let (server, _) = start_streaming_sse_server(Vec::new()).await; + let mut stream = connect(server.uri()).await; + send_request( + &mut stream, + "GET /v1/models HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ) + .await; + let response = read_to_end(&mut stream).await; + let (headers, body) = split_response(&response); + assert_eq!(status_code(headers), 200); + assert_eq!( + header_value(headers, "content-type"), + Some("application/json") + ); + let parsed: serde_json::Value = serde_json::from_str(body).expect("parse json body"); + assert_eq!( + parsed, + serde_json::json!({ + "data": [], + "object": "list" + }) + ); + server.shutdown().await; + } + + #[tokio::test] + async fn post_responses_streams_in_order_and_closes() { + let chunks = vec![ + StreamingSseChunk { + gate: None, + body: "event: one\n\n".to_string(), + }, + StreamingSseChunk { + gate: None, + body: "event: two\n\n".to_string(), + }, + ]; + let (server, mut completions) = start_streaming_sse_server(vec![chunks]).await; + let mut stream = connect(server.uri()).await; + send_request( + &mut stream, + "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + ) + .await; + let response = read_to_end(&mut stream).await; + let (headers, body) = split_response(&response); + assert_eq!(status_code(headers), 200); + assert_eq!( + header_value(headers, "content-type"), + Some("text/event-stream") + ); + assert_eq!(body, "event: one\n\nevent: two\n\n"); + let mut extra = [0u8; 1]; + let read = stream.read(&mut extra).await.expect("read after eof"); + assert_eq!(read, 0); + let completion = completions.pop().expect("completion receiver"); + let timestamp = completion.await.expect("completion timestamp"); + assert!(timestamp > 0); + server.shutdown().await; + } + + #[tokio::test] + async fn none_gate_streams_immediately() { + let chunks = vec![StreamingSseChunk { + gate: None, + body: "event: immediate\n\n".to_string(), + }]; + let (server, _) = start_streaming_sse_server(vec![chunks]).await; + let mut stream = connect(server.uri()).await; + send_request( + &mut stream, + "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + ) + .await; + let (headers, remainder) = read_until(&mut stream, "\r\n\r\n").await; + let (headers, _) = split_response(&headers); + assert_eq!(status_code(headers), 200); + let immediate = format!("{remainder}{}", read_to_end(&mut stream).await); + assert_eq!(immediate, "event: immediate\n\n"); + server.shutdown().await; + } + + #[tokio::test] + async fn post_responses_with_no_queue_returns_500() { + let (server, _) = start_streaming_sse_server(Vec::new()).await; + let mut stream = connect(server.uri()).await; + send_request( + &mut stream, + "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + ) + .await; + let response = read_to_end(&mut stream).await; + let (headers, body) = split_response(&response); + assert_eq!(status_code(headers), 500); + assert_eq!(header_value(headers, "content-type"), Some("text/plain")); + assert_eq!(body, "no responses queued"); + server.shutdown().await; + } + + #[tokio::test] + async fn gated_chunks_wait_for_signal_and_preserve_order() { + let (gate_one_tx, gate_one_rx) = oneshot::channel(); + let (gate_two_tx, gate_two_rx) = oneshot::channel(); + let chunks = vec![ + StreamingSseChunk { + gate: Some(gate_one_rx), + body: "event: one\n\n".to_string(), + }, + StreamingSseChunk { + gate: Some(gate_two_rx), + body: "event: two\n\n".to_string(), + }, + ]; + let (server, _) = start_streaming_sse_server(vec![chunks]).await; + let mut stream = connect(server.uri()).await; + send_request( + &mut stream, + "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + ) + .await; + let (headers, remainder) = read_until(&mut stream, "\r\n\r\n").await; + let (headers, _) = split_response(&headers); + assert_eq!(status_code(headers), 200); + assert_eq!( + header_value(headers, "content-type"), + Some("text/event-stream") + ); + assert!( + remainder.is_empty(), + "unexpected body before gate: {remainder:?}" + ); + let mut scratch = [0u8; 32]; + let pending = timeout(Duration::from_millis(200), stream.read(&mut scratch)).await; + assert!(pending.is_err()); + + let _ = gate_one_tx.send(()); + let mut first_chunk = vec![0u8; "event: one\n\n".len()]; + stream + .read_exact(&mut first_chunk) + .await + .expect("read first chunk"); + assert_eq!(String::from_utf8_lossy(&first_chunk), "event: one\n\n"); + let pending = timeout(Duration::from_millis(200), stream.read(&mut scratch)).await; + assert!(pending.is_err()); + + let _ = gate_two_tx.send(()); + let remaining = read_to_end(&mut stream).await; + assert_eq!(remaining, "event: two\n\n"); + server.shutdown().await; + } + + #[tokio::test] + async fn multiple_responses_are_fifo_and_completion_timestamps_monotonic() { + let first_chunks = vec![StreamingSseChunk { + gate: None, + body: "event: first\n\n".to_string(), + }]; + let second_chunks = vec![StreamingSseChunk { + gate: None, + body: "event: second\n\n".to_string(), + }]; + let (server, mut completions) = + start_streaming_sse_server(vec![first_chunks, second_chunks]).await; + + let mut first_stream = connect(server.uri()).await; + send_request( + &mut first_stream, + "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + ) + .await; + let first_response = read_to_end(&mut first_stream).await; + let (_, first_body) = split_response(&first_response); + assert_eq!(first_body, "event: first\n\n"); + + let mut second_stream = connect(server.uri()).await; + send_request( + &mut second_stream, + "POST /v1/responses HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n", + ) + .await; + let second_response = read_to_end(&mut second_stream).await; + let (_, second_body) = split_response(&second_response); + assert_eq!(second_body, "event: second\n\n"); + + let first_completion = completions.remove(0); + let second_completion = completions.remove(0); + let first_timestamp = first_completion.await.expect("first completion"); + let second_timestamp = second_completion.await.expect("second completion"); + assert!(first_timestamp > 0); + assert!(second_timestamp > 0); + assert!(first_timestamp <= second_timestamp); + assert!(completions.is_empty()); + server.shutdown().await; + } + + #[tokio::test] + async fn unknown_route_returns_404() { + let (server, _) = start_streaming_sse_server(Vec::new()).await; + let mut stream = connect(server.uri()).await; + send_request( + &mut stream, + "GET /v1/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ) + .await; + let response = read_to_end(&mut stream).await; + let (headers, body) = split_response(&response); + assert_eq!(status_code(headers), 404); + assert_eq!(header_value(headers, "content-type"), Some("text/plain")); + assert_eq!(body, "not found"); + server.shutdown().await; + } + + #[tokio::test] + async fn malformed_request_returns_400() { + let (server, _) = start_streaming_sse_server(Vec::new()).await; + let mut stream = connect(server.uri()).await; + send_request(&mut stream, "BAD\r\n\r\n").await; + let response = read_to_end(&mut stream).await; + let (headers, body) = split_response(&response); + assert_eq!(status_code(headers), 400); + assert_eq!(header_value(headers, "content-type"), Some("text/plain")); + assert_eq!(body, "bad request"); + server.shutdown().await; + } + + #[tokio::test] + async fn responses_post_drains_request_body() { + let response_body = r#"event: response.completed +data: {"type":"response.completed","response":{"id":"resp-1"}} + +"#; + let (server, mut completions) = start_streaming_sse_server(vec![vec![StreamingSseChunk { + gate: None, + body: response_body.to_string(), + }]]) + .await; + + let url = format!("{}/v1/responses", server.uri()); + let payload = serde_json::json!({ + "model": "gpt-5.1", + "instructions": "test", + "input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}], + "stream": true + }); + + let resp = reqwest::Client::new() + .post(url) + .json(&payload) + .send() + .await + .expect("send request"); + assert_eq!(resp.status(), StatusCode::OK); + + let bytes = resp.bytes().await.expect("read response body"); + assert_eq!(bytes, response_body.as_bytes()); + + let completion = completions.remove(0); + let completed_at = completion.await.expect("completion timestamp"); + assert!(completed_at > 0); + + server.shutdown().await; + } + + #[tokio::test] + async fn read_http_request_returns_after_header_terminator() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let (tx, rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept client"); + let (request, body) = read_http_request(&mut stream).await; + let _ = tx.send((request, body)); + }); + + let mut client = TcpStream::connect(addr) + .await + .expect("connect to test listener"); + let request = "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + client + .write_all(request.as_bytes()) + .await + .expect("write request"); + let (received, body) = timeout(Duration::from_millis(200), rx) + .await + .expect("read_http_request timed out") + .expect("receive request"); + assert_eq!(received, request); + assert!(body.is_empty()); + drop(client); + let _ = server_task.await; + } + + #[test] + fn parse_request_line_handles_valid_and_invalid() { + assert_eq!(parse_request_line(""), None); + assert_eq!(parse_request_line("BAD"), None); + assert_eq!( + parse_request_line("GET /v1/models HTTP/1.1"), + Some(("GET", "/v1/models")) + ); + } + + #[tokio::test] + async fn take_next_stream_consumes_in_lockstep() { + let (first_tx, first_rx) = oneshot::channel(); + let (second_tx, second_rx) = oneshot::channel(); + let state = TokioMutex::new(StreamingSseState { + responses: VecDeque::from(vec![ + vec![StreamingSseChunk { + gate: None, + body: "first".to_string(), + }], + vec![StreamingSseChunk { + gate: None, + body: "second".to_string(), + }], + ]), + completions: VecDeque::from(vec![first_tx, second_tx]), + }); + + let (first_chunks, first_completion) = + take_next_stream(&state).await.expect("first stream"); + assert_eq!(first_chunks[0].body, "first"); + let _ = first_completion.send(11); + assert_eq!(first_rx.await.expect("first completion"), 11); + + let (second_chunks, second_completion) = + take_next_stream(&state).await.expect("second stream"); + assert_eq!(second_chunks[0].body, "second"); + let _ = second_completion.send(22); + assert_eq!(second_rx.await.expect("second completion"), 22); + + let third = take_next_stream(&state).await; + assert!(third.is_none()); + } + + #[tokio::test] + async fn shutdown_terminates_accept_loop() { + let (server, _) = start_streaming_sse_server(Vec::new()).await; + let shutdown = timeout(Duration::from_millis(200), server.shutdown()).await; + assert!(shutdown.is_ok()); + } +} diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 7a12a24a69..59379d7686 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -25,6 +25,7 @@ use wiremock::MockServer; use crate::load_default_config_for_test; use crate::responses::get_responses_request_bodies; use crate::responses::start_mock_server; +use crate::streaming_sse::StreamingSseServer; use crate::wait_for_event; type ConfigMutator = dyn FnOnce(&mut Config) + Send; @@ -89,6 +90,16 @@ impl TestCodexBuilder { self.build_with_home(server, home, None).await } + pub async fn build_with_streaming_server( + &mut self, + server: &StreamingSseServer, + ) -> anyhow::Result { + let base_url = server.uri(); + let home = Arc::new(TempDir::new()?); + self.build_with_home_and_base_url(format!("{base_url}/v1"), home, None) + .await + } + pub async fn resume( &mut self, server: &wiremock::MockServer, @@ -104,8 +115,28 @@ impl TestCodexBuilder { home: Arc, resume_from: Option, ) -> anyhow::Result { - let (config, cwd) = self.prepare_config(server, &home).await?; + let base_url = format!("{}/v1", server.uri()); + let (config, cwd) = self.prepare_config(base_url, &home).await?; + self.build_from_config(config, cwd, home, resume_from).await + } + async fn build_with_home_and_base_url( + &mut self, + base_url: String, + home: Arc, + resume_from: Option, + ) -> anyhow::Result { + let (config, cwd) = self.prepare_config(base_url, &home).await?; + self.build_from_config(config, cwd, home, resume_from).await + } + + async fn build_from_config( + &mut self, + config: Config, + cwd: Arc, + home: Arc, + resume_from: Option, + ) -> anyhow::Result { let auth = self.auth.clone(); let conversation_manager = ConversationManager::with_models_provider_and_home( auth.clone(), @@ -139,11 +170,11 @@ impl TestCodexBuilder { async fn prepare_config( &mut self, - server: &wiremock::MockServer, + base_url: String, home: &TempDir, ) -> anyhow::Result<(Config, Arc)> { let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), + base_url: Some(base_url), ..built_in_model_providers()["openai"].clone() }; let cwd = Arc::new(TempDir::new()?); diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index bf01196c78..c139964d07 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -41,6 +41,7 @@ use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; use tokio::time::Duration; @@ -298,6 +299,108 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_preserve_builtin_presets() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let remote_model = test_remote_model("remote-alpha", ModelVisibility::List, 0); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model.clone()], + etag: String::new(), + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home); + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::from_api_key("dummy"); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + manager + .refresh_available_models(&config) + .await + .expect("refresh succeeds"); + + let available = manager.list_models(&config).await; + let remote = available + .iter() + .find(|model| model.model == "remote-alpha") + .expect("remote model should be listed"); + let mut expected_remote: ModelPreset = remote_model.into(); + expected_remote.is_default = true; + assert_eq!(*remote, expected_remote); + assert!( + available + .iter() + .any(|model| model.model == "gpt-5.1-codex-max"), + "builtin presets should remain available after refresh" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_hide_picker_only_models() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let remote_model = test_remote_model("codex-auto-balanced", ModelVisibility::Hide, 0); + mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + etag: String::new(), + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home); + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let selected = manager.get_model(&None, &config).await; + assert_eq!(selected, "gpt-5.1-codex-max"); + + let available = manager.list_models(&config).await; + assert!( + available + .iter() + .all(|model| model.model != "codex-auto-balanced"), + "hidden models should not appear in the picker list" + ); + + Ok(()) +} + async fn wait_for_model_available( manager: &Arc, slug: &str, @@ -362,3 +465,32 @@ where conversation_manager, }) } + +fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo { + ModelInfo { + slug: slug.to_string(), + display_name: format!("{slug} display"), + description: Some(format!("{slug} description")), + default_reasoning_level: ReasoningEffort::Medium, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::ShellCommand, + visibility, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority, + upgrade: None, + base_instructions: None, + supports_reasoning_summaries: false, + support_verbosity: false, + default_verbosity: None, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(10_000), + supports_parallel_tool_calls: false, + context_window: None, + reasoning_summary_format: ReasoningSummaryFormat::None, + experimental_supported_tools: Vec::new(), + } +} diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs index 10e972b3de..34124b35dc 100644 --- a/codex-rs/core/tests/suite/shell_command.rs +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -13,10 +13,15 @@ use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; use serde_json::json; -fn shell_responses(call_id: &str, command: &str, login: Option) -> Vec { +fn shell_responses_with_timeout( + call_id: &str, + command: &str, + login: Option, + timeout_ms: i64, +) -> Vec { let args = json!({ "command": command, - "timeout_ms": 2_000, + "timeout_ms": timeout_ms, "login": login, }); @@ -36,6 +41,10 @@ fn shell_responses(call_id: &str, command: &str, login: Option) -> Vec) -> Vec { + shell_responses_with_timeout(call_id, command, login, 2_000) +} + async fn shell_command_harness_with( configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder, ) -> Result { @@ -54,6 +63,20 @@ async fn mount_shell_responses( mount_sse_sequence(harness.server(), shell_responses(call_id, command, login)).await; } +async fn mount_shell_responses_with_timeout( + harness: &TestCodexHarness, + call_id: &str, + command: &str, + login: Option, + timeout_ms: i64, +) { + mount_sse_sequence( + harness.server(), + shell_responses_with_timeout(call_id, command, login, timeout_ms), + ) + .await; +} + fn assert_shell_command_output(output: &str, expected: &str) -> Result<()> { let normalized_output = output .replace("\r\n", "\n") @@ -172,3 +195,32 @@ async fn pipe_output_without_login() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-timeout"; + let command = if cfg!(windows) { + "timeout /t 5" + } else { + "sleep 5" + }; + mount_shell_responses_with_timeout(&harness, call_id, command, None, 200).await; + harness + .submit("run a long command with a short timeout") + .await?; + + let output = harness.function_call_stdout(call_id).await; + let normalized_output = output + .replace("\r\n", "\n") + .replace('\r', "\n") + .trim_end_matches('\n') + .to_string(); + let expected_pattern = r"(?s)^Exit code: 124\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\ncommand timed out after [0-9]+ milliseconds\n?$"; + assert_regex_match(expected_pattern, &normalized_output); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index abb55f3a14..0ec5221f40 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] #![allow(clippy::unwrap_used)] +use std::fs; use std::time::Duration; use std::time::Instant; @@ -13,16 +14,22 @@ use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_shell_command_call_with_args; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use tokio::sync::oneshot; async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { let session_model = test.session_configured.model.clone(); @@ -280,3 +287,123 @@ async fn tool_results_grouped() -> anyhow::Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_tools_start_before_response_completed_when_stream_delayed() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let output_file = tempfile::NamedTempFile::new()?; + let output_path = output_file.path(); + let first_response_id = "resp-1"; + let second_response_id = "resp-2"; + + let command = format!( + "perl -MTime::HiRes -e 'print int(Time::HiRes::time()*1000), \"\\n\"' >> \"{}\"", + output_path.display() + ); + let args = json!({ + "command": command, + "timeout_ms": 1_000, + }); + + let first_chunk = sse(vec![ + ev_response_created(first_response_id), + ev_shell_command_call_with_args("call-1", &args), + ev_shell_command_call_with_args("call-2", &args), + ev_shell_command_call_with_args("call-3", &args), + ev_shell_command_call_with_args("call-4", &args), + ]); + let second_chunk = sse(vec![ev_completed(first_response_id)]); + let follow_up = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed(second_response_id), + ]); + + let (first_gate_tx, first_gate_rx) = oneshot::channel(); + let (completion_gate_tx, completion_gate_rx) = oneshot::channel(); + let (follow_up_gate_tx, follow_up_gate_rx) = oneshot::channel(); + let (streaming_server, completion_receivers) = start_streaming_sse_server(vec![ + vec![ + StreamingSseChunk { + gate: Some(first_gate_rx), + body: first_chunk, + }, + StreamingSseChunk { + gate: Some(completion_gate_rx), + body: second_chunk, + }, + ], + vec![StreamingSseChunk { + gate: Some(follow_up_gate_rx), + body: follow_up, + }], + ]) + .await; + + let mut builder = test_codex().with_model("gpt-5.1"); + let test = builder + .build_with_streaming_server(&streaming_server) + .await?; + + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "stream delayed completion".into(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let _ = first_gate_tx.send(()); + let _ = follow_up_gate_tx.send(()); + + let timestamps = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let contents = fs::read_to_string(output_path)?; + let timestamps = contents + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + line.trim() + .parse::() + .map_err(|err| anyhow::anyhow!("invalid timestamp {line:?}: {err}")) + }) + .collect::, _>>()?; + if timestamps.len() == 4 { + return Ok::<_, anyhow::Error>(timestamps); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + + let _ = completion_gate_tx.send(()); + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let mut completion_iter = completion_receivers.into_iter(); + let completed_at = completion_iter + .next() + .expect("completion receiver missing") + .await + .expect("completion timestamp missing"); + let count = i64::try_from(timestamps.len()).expect("timestamp count fits in i64"); + assert_eq!(count, 4); + + for timestamp in timestamps { + assert!( + timestamp < completed_at, + "timestamp {timestamp} should be before completed {completed_at}" + ); + } + + streaming_server.shutdown().await; + + Ok(()) +} diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index fd542feda9..eb4e9cebde 100644 --- a/codex-rs/tui2/Cargo.toml +++ b/codex-rs/tui2/Cargo.toml @@ -106,8 +106,8 @@ arboard = { workspace = true } [dev-dependencies] -codex-core = { workspace = true, features = ["test-support"] } assert_matches = { workspace = true } +codex-core = { workspace = true, features = ["test-support"] } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/tui2/docs/streaming_wrapping_design.md b/codex-rs/tui2/docs/streaming_wrapping_design.md new file mode 100644 index 0000000000..28937c377f --- /dev/null +++ b/codex-rs/tui2/docs/streaming_wrapping_design.md @@ -0,0 +1,85 @@ +# Streaming Markdown Wrapping & Animation – TUI2 Notes + +This document mirrors the original `tui/streaming_wrapping_design.md` and +captures how the same concerns apply to the new `tui2` crate. It exists so that +future viewport and streaming work in TUI2 can rely on the same context without +having to cross‑reference the legacy TUI implementation. + +At a high level, the design constraints are the same: + +- Streaming agent responses are rendered incrementally, with an animation loop + that reveals content over time. +- Non‑streaming history cells are rendered width‑agnostically and wrapped only + at display time, so they reflow correctly when the terminal is resized. +- Streaming content should eventually follow the same “wrap on display” model so + the transcript reflows consistently across width changes, without regressing + animation or markdown semantics. + +## 1. Where streaming is implemented in TUI2 + +TUI2 keeps the streaming pipeline conceptually aligned with the legacy TUI but +in a separate crate: + +- `tui2/src/markdown_stream.rs` implements the markdown streaming collector and + animation controller for agent deltas. +- `tui2/src/chatwidget.rs` integrates streamed content into the transcript via + `HistoryCell` implementations. +- `tui2/src/history_cell.rs` provides the concrete history cell types used by + the inline transcript and overlays. +- `tui2/src/wrapping.rs` contains the shared text wrapping utilities used by + both streaming and non‑streaming render paths: + - `RtOptions` describes viewport‑aware wrapping (width, indents, algorithm). + - `word_wrap_line`, `word_wrap_lines`, and `word_wrap_lines_borrowed` provide + span‑aware wrapping that preserves markdown styling and emoji width. + +As in the original TUI, the key tension is between: + +- **Pre‑wrapping streamed content at commit time** (simpler animation, but + baked‑in splits that don’t reflow), and +- **Deferring wrapping to render time** (better reflow, but requires a more + sophisticated streaming cell model or recomputation on each frame). + +## 2. Current behavior and limitations + +TUI2 is intentionally conservative for now: + +- Streaming responses use the same markdown streaming and wrapping utilities as + the legacy TUI, with width decisions made near the streaming collector. +- The transcript viewport (`App::render_transcript_cells` in + `tui2/src/app.rs`) always uses `word_wrap_lines_borrowed` against the + current `Rect` width, so: + - Non‑streaming cells reflow naturally on resize. + - Streamed cells respect whatever wrapping was applied when their lines were + constructed, and may not fully “un‑wrap” if that work happened at a fixed + width earlier in the pipeline. + +This means TUI2 shares the same fundamental limitation documented in the +original design note: streamed paragraphs can retain historical wrap decisions +made at the time they were streamed, even if the viewport later grows wider. + +## 3. Design directions (forward‑looking) + +The options outlined in the legacy document apply here as well: + +1. **Keep the current behavior but clarify tests and documentation.** + - Ensure tests in `tui2/src/markdown_stream.rs`, `tui2/src/markdown_render.rs`, + `tui2/src/history_cell.rs`, and `tui2/src/wrapping.rs` encode the current + expectations around streaming, wrapping, and emoji / markdown styling. +2. **Move towards width‑agnostic streaming cells.** + - Introduce a dedicated streaming history cell that stores the raw markdown + buffer and lets `HistoryCell::display_lines(width)` perform both markdown + rendering and wrapping based on the current viewport width. + - Keep the commit animation logic expressed in terms of “logical” positions + (e.g., number of tokens or lines committed) rather than pre‑wrapped visual + lines at a fixed width. +3. **Hybrid “visual line count” model.** + - Track committed visual lines as a scalar and re‑render the streamed prefix + at the current width, revealing only the first `N` visual lines on each + animation tick. + +TUI2 does not yet implement these refactors; it intentionally stays close to +the legacy behavior while the viewport work (scrolling, selection, exit +transcripts) is being ported. This document exists to make that trade‑off +explicit for TUI2 and to provide a natural home for any TUI2‑specific streaming +wrapping notes as the design evolves. + diff --git a/codex-rs/tui2/docs/tui_viewport_and_history.md b/codex-rs/tui2/docs/tui_viewport_and_history.md new file mode 100644 index 0000000000..8485e93646 --- /dev/null +++ b/codex-rs/tui2/docs/tui_viewport_and_history.md @@ -0,0 +1,454 @@ +# TUI2 Viewport, Transcript, and History – Design Notes + +This document describes the viewport and history model we are implementing in the new +`codex-rs/tui2` crate. It builds on lessons from the legacy TUI and explains why we moved away +from directly writing history into terminal scrollback. + +The target audience is Codex developers and curious contributors who want to understand or +critique how TUI2 owns its viewport, scrollback, and suspend behavior. + +Unless stated otherwise, references to “the TUI” in this document mean the TUI2 implementation; +when we mean the legacy TUI specifically, we call it out explicitly. + +--- + +## 1. Problem Overview + +Historically, the legacy TUI tried to “cooperate” with the terminal’s own scrollback: + +- The inline viewport sat somewhere above the bottom of the screen. +- When new history arrived, we tried to insert it directly into the terminal scrollback above the + viewport. +- On certain transitions (e.g. switching sessions, overlays), we cleared and re‑wrote portions of + the screen from scratch. + +This had several failure modes: + +- **Terminal‑dependent behavior.** + - Different terminals handle scroll regions, clears, and resize semantics differently. + - What looked correct in one terminal could drop or duplicate content in another. + +- **Resizes and layout churn.** + - The TUI reacts to resizes, focus changes, and overlay transitions. + - When the viewport moved or its size changed, our attempts to keep scrollback “aligned” with the + in‑memory history could go out of sync. + - In practice this meant: + - Some lines were lost or overwritten. + - Others were duplicated or appeared in unexpected places. + +- **“Clear and rewrite everything” didn’t save us.** + - We briefly tried a strategy of clearing large regions (or the full screen) and re‑rendering + history when the layout changed. + - This ran into two issues: + - Terminals treat full clears differently. For example, Terminal.app often leaves the cleared + screen as a “page” at the top of scrollback, some terminals interpret only a subset of the + ANSI clear/scrollback codes, and others (like iTerm2) gate “clear full scrollback” behind + explicit user consent. + - Replaying a long session is expensive and still subject to timing/race conditions with user + output (e.g. shell prompts) when we weren’t in alt screen. + +The net result: the legacy TUI could not reliably guarantee “the history you see on screen is complete, in +order, and appears exactly once” across terminals, resizes, suspend/resume, and overlay transitions. + +--- + +## 2. Goals + +The redesign is guided by a few explicit goals: + +1. **Codex, not the terminal, owns the viewport.** + - The in‑memory transcript (a list of history entries) is the single source of truth for what’s + on screen. + - The TUI decides how to map that transcript into the current viewport; scrollback becomes an + output target, not an extra data structure we try to maintain. + +2. **History must be correct, ordered, and never silently dropped.** + - Every logical history cell should either: + - Be visible in the TUI, or + - Have been printed into scrollback as part of a suspend/exit flow. + - We would rather (rarely) duplicate content than risk losing it. + +3. **Avoid unnecessary duplication.** + - When emitting history to scrollback (on suspend or exit), print each logical cell’s content at + most once. + - Streaming cells are allowed to be “re‑seen” as they grow, but finished cells should not keep + reappearing. + +4. **Behave sensibly under resizes.** + - TUI rendering should reflow to the current width on every frame. + - History printed to scrollback may have been wrapped at different widths over time; that is + acceptable, but it must not cause missing content or unbounded duplication. + +5. **Suspend/alt‑screen interaction is predictable.** + - `Ctrl+Z` should: + - Cleanly exit alt screen, if active. + - Print a consistent transcript prefix into normal scrollback. + - Resume with the TUI fully redrawn, without stale artifacts. + +--- + +## 3. New Viewport & Transcript Model + +### 3.1 Transcript as a logical sequence of cells + +At a high level, the TUI transcript is a list of “cells”, each representing one logical thing in +the conversation: + +- A user prompt (with padding and a distinct background). +- An agent response (which may arrive in multiple streaming chunks). +- System or info rows (session headers, migration banners, reasoning summaries, etc.). + +Each cell knows how to draw itself for a given width: how many lines it needs, what prefixes to +use, how to style its content. The transcript itself is purely logical: + +- It has no scrollback coordinates or terminal state baked into it. +- It can be re‑rendered for any viewport width. + +The TUI’s job is to take this logical sequence and decide how much of it fits into the current +viewport, and how it should be wrapped and styled on screen. + +### 3.2 Building viewport lines from the transcript + +To render the main transcript area above the composer, the TUI: + +1. Defines a “transcript region” as the full frame minus the height of the bottom input area. +2. Flattens all cells into a list of visual lines, remembering for each visual line which cell it + came from and which line within that cell it corresponds to. +3. Uses this flattened list plus a scroll position to decide which visual line should appear at the + top of the region. +4. Clears the transcript region and draws the visible slice of lines into it. +5. For user messages, paints the entire row background (including padding lines) so the user block + stands out even when it does not fill the whole width. +6. Applies selection styling and other overlays on top of the rendered lines. + +Scrolling (mouse wheel, PgUp/PgDn, Home/End) operates entirely in terms of these flattened lines +and the current scroll anchor. The terminal’s own scrollback is not part of this calculation; it +only ever sees fully rendered frames. + +### 3.3 Alternate screen, overlays, and redraw guarantees + +The TUI uses the terminal’s alternate screen for: + +- The main interactive chat session (so the viewport can cover the full terminal). +- Full‑screen overlays such as the transcript pager, diff view, model migration screen, and + onboarding. + +Conceptually: + +- Entering alt screen: + - Switches the terminal into alt screen and expands the viewport to cover the full terminal. + - Clears that alt‑screen buffer. + +- Leaving alt screen: + - Disables “alternate scroll” so mouse wheel events behave predictably. + - Returns to the normal screen. + +- On leaving overlays and on resuming from suspend, the TUI viewport is explicitly cleared and fully + redrawn: + - This prevents stale overlay content or shell output from lingering in the TUI area. + - The next frame reconstructs the UI entirely from the in‑memory transcript and other state, not + from whatever the terminal happened to remember. + +Alt screen is therefore treated as a temporary render target. The only authoritative copy of the UI +is the in‑memory state. + +--- + +## 4. Mouse, Selection, and Scrolling + +Mouse interaction is a first‑class part of the new design: + +- **Scrolling.** + - Mouse wheel scrolls the transcript in fixed line increments. + - Keyboard shortcuts (PgUp/PgDn/Home/End) use the same scroll model, so the footer can show + consistent hints regardless of input device. + +- **Selection.** + - A click‑and‑drag gesture defines a linear text selection in terms of the flattened transcript + lines (not raw buffer coordinates). + - Selection tracks the _content_ rather than a fixed screen row. When the transcript scrolls, the + selection moves along with the underlying lines instead of staying glued to a particular Y + position. + - The selection only covers the “transcript text” area; it intentionally skips the left gutter + that we use for bullets/prefixes. + +- **Copy.** + - When the user triggers copy, the TUI re‑renders just the transcript region off‑screen using the + same wrapping as the visible view. + - It then walks the selected lines and columns in that off‑screen buffer to reconstruct the exact + text region the user highlighted (including internal spaces and empty lines). + - That text is sent to the system clipboard and a status footer indicates success or failure. + +Because scrolling, selection, and copy all operate on the same flattened transcript representation, +they remain consistent even as the viewport resizes or the chat composer grows/shrinks. Owning our +own scrolling also means we must own mouse interactions end‑to‑end: if we left scrolling entirely +to the terminal, we could not reliably line up selections with transcript content or avoid +accidentally copying gutter/margin characters instead of just the conversation text. + +--- + +## 5. Printing History to Scrollback + +We still want the final session (and suspend points) to appear in the user’s normal scrollback, but +we no longer try to maintain scrollback in lock‑step with the TUI frame. Instead, we treat +scrollback as an **append‑only log** of logical transcript cells. + +In practice this means: + +- The TUI may print history both when you suspend (`Ctrl+Z`) and when you exit. +- Some users may prefer to only print on exit (for example to keep scrollback quieter during long + sessions). The current design anticipates gating suspend‑time printing behind a config toggle so + that this behavior can be made opt‑in or opt‑out without touching the core viewport logic, but + that switch has not been implemented yet. + +### 5.1 Cell‑based high‑water mark + +Internally, the TUI keeps a simple “high‑water mark” for history printing: + +- Think of this as “how many cells at the front of the transcript have already been sent to + scrollback.” +- It is just a counter over the logical transcript, not over wrapped lines. +- It moves forward only when we have actually printed more history. + +This means we never try to guess “how many terminal lines have already been printed”; we only +remember that “the first N logical entries are done.” + +### 5.2 Rendering new cells for scrollback + +When we need to print history (on suspend or exit), we: + +1. Take the suffix of the transcript that lies beyond the high‑water mark. +2. Render just that suffix into styled lines at the **current** terminal width. +3. Write those lines to stdout. +4. Advance the high‑water mark to include all cells we just printed. + +Older cells are never re‑rendered for scrollback; they remain in whatever wrapping they had when +they were first printed. This avoids the line‑count–based bugs we had before while still allowing +the on‑screen TUI to reflow freely. + +### 5.3 Suspend (`Ctrl+Z`) flow + +On suspend (typically `Ctrl+Z` on Unix): + +- Before yielding control back to the shell, the TUI: + - Leaves alt screen if it is active and restores normal terminal modes. + - Determines which transcript cells have not yet been printed and renders them for the current + width. + - Prints those new lines once into normal scrollback. + - Marks those cells as printed in the high‑water mark. + - Finally, sends the process to the background. + +On `fg`, the process resumes, re‑enters TUI modes, and redraws the viewport from the in‑memory +transcript. The history printed during suspend stays in scrollback and is not touched again. + +### 5.4 Exit flow + +When the TUI exits, we follow the same principle: + +- We compute the suffix of the transcript that has not yet been printed (taking into account any + prior suspends). +- We render just that suffix to styled lines at the current width. +- The outer `main` function leaves alt screen, restores the terminal, and prints those lines, plus a + blank line and token usage summary. + +If you never suspended, exit prints the entire transcript once. If you did suspend one or more +times, exit prints only the cells appended after the last suspend. In both cases, each logical +conversation entry reaches scrollback exactly once. + +--- + +## 6. Streaming, Width Changes, and Tradeoffs + +### 6.1 Streaming cells + +Streaming agent responses are represented as a sequence of history entries: + +- The first chunk produces a “first line” entry for the message. +- Subsequent chunks produce continuation entries that extend that message. + +From the history/scrollback perspective: + +- Each streaming chunk is just another entry in the logical transcript. +- The high‑water mark is a simple count of how many entries at the _front_ of the transcript have + already been printed. +- As new streaming chunks arrive, they are appended as new entries and will be included the next + time we print history on suspend or exit. + +We do **not** attempt to reprint or retroactively merge older chunks. In scrollback you will see the +streaming response as a series of discrete blocks, matching the internal history structure. + +Today, streaming rendering still “bakes in” some width at the time chunks are committed: line breaks +for the streaming path are computed using the width that was active at the time, and stored in the +intermediate representation. This is a known limitation and is called out in more detail in +`codex-rs/tui2/docs/streaming_wrapping_design.md`; a follow‑up change will make streaming behavior +match the rest of the transcript more closely (wrap only at display time, not at commit time). + +### 6.2 Width changes over time + +Because we now use a **cell‑level** high‑water mark instead of a visual line‑count, width changes +are handled gracefully: + +- On every suspend/exit, we render the not‑yet‑printed suffix of the transcript at the **current** + width and append those lines. +- Previously printed entries remain in scrollback with whatever wrapping they had at the time they + were printed. +- We no longer rely on “N lines printed before, therefore skip N lines of the newly wrapped + transcript,” which was the source of dropped and duplicated content when widths changed. + +This does mean scrollback can contain older cells wrapped for narrower or wider widths than the +final terminal size, but: + +- Each logical cell’s content appears exactly once. +- New cells are append‑only and never overwrite or implicitly “shrink” earlier content. +- The on‑screen TUI always reflows to the current width independently of scrollback. + +If we later choose to also re‑emit the “currently streaming” cell when printing on suspend (to make +sure the latest chunk of a long answer is always visible in scrollback), that would intentionally +duplicate a small number of lines at the boundary of that cell. The design assumes any such behavior +would be controlled by configuration (for example, by disabling suspend‑time printing entirely for +users who prefer only exit‑time output). + +### 6.3 Why not reflow scrollback? + +In theory we could try to reflow already‑printed content when widths change by: + +- Recomputing the entire transcript at the new width, and +- Printing diffs that “rewrite” old regions in scrollback. + +In practice, this runs into the same issues that motivated the redesign: + +- Terminals treat full clears and scroll regions differently. +- There is no portable way to “rewrite” arbitrary portions of scrollback above the visible buffer. +- Interleaving user output (e.g. shell prompts after suspend) makes it impossible to reliably + reconstruct the original scrollback structure. + +We therefore deliberately accept that scrollback is **append‑only** and not subject to reflow; +correctness is measured in terms of logical transcript content, not pixel‑perfect layout. + +--- + +## 7. Backtrack and Overlays (Context) + +While this document is focused on viewport and history, it’s worth mentioning a few related +behaviors that rely on the same model. + +### 7.1 Transcript overlay and backtrack + +The transcript overlay (pager) is a full‑screen view of the same logical transcript: + +- When opened, it takes a snapshot of the current transcript and renders it in an alt‑screen + overlay. +- Backtrack mode (`Esc` sequences) walks backwards through user messages in that snapshot and + highlights the candidate “edit from here” point. +- Confirming a backtrack request forks the conversation on the server and trims the in‑memory + transcript so that only history up to the chosen user message remains, then re‑renders that prefix + in the main view. + +The overlay is purely a different _view_ of the same transcript; it never infers anything from +scrollback. + +--- + +## 8. Summary of Tradeoffs + +**What we gain:** + +- The TUI has a clear, single source of truth for history (the in‑memory transcript). +- Viewport rendering is deterministic and independent of scrollback. +- Suspend and exit flows: + - Print each logical history cell exactly once. + - Are robust to terminal width changes. + - Interact cleanly with alt screen and raw‑mode toggling. +- Streaming, overlays, selection, and backtrack all share the same logical history model. +- Because cells are always re‑rendered live from the transcript, per‑cell interactions can become + richer over time. Instead of treating the transcript as “dead text”, we can make individual + entries interactive after they are rendered: expanding or contracting tool calls, diffs, or + reasoning summaries in place, jum…truncated… \*\*\* + +--- + +## 9. TUI2 Implementation Notes + +This section maps the design above onto the `codex-rs/tui2` crate so future viewport work has +concrete code pointers. + +### 9.1 Transcript state and layout + +The main app struct (`codex-rs/tui2/src/app.rs`) tracks the transcript and viewport state with: + +- `transcript_cells: Vec>` – the logical history. +- `transcript_scroll: TranscriptScroll` – whether the viewport is pinned to the bottom or + anchored at a specific cell/line pair. +- `transcript_selection: TranscriptSelection` – a selection expressed in screen coordinates over + the flattened transcript region. +- `transcript_view_top` / `transcript_total_lines` – the current viewport’s top line index and + total number of wrapped lines for the inline transcript area. + +### 9.2 Rendering, wrapping, and selection + +`App::render_transcript_cells` defines the transcript region, builds flattened lines via +`App::build_transcript_lines`, wraps them with `word_wrap_lines_borrowed` from +`codex-rs/tui2/src/wrapping.rs`, and applies selection via `apply_transcript_selection` before +writing to the frame buffer. + +Streaming wrapping details live in `codex-rs/tui2/docs/streaming_wrapping_design.md`. + +### 9.3 Input, selection, and footer state + +Mouse handling lives in `App::handle_mouse_event`, keyboard scrolling in +`App::handle_key_event`, selection rendering in `App::apply_transcript_selection`, and copy in +`App::copy_transcript_selection` plus `codex-rs/tui2/src/clipboard_copy.rs`. Scroll/selection UI +state is forwarded through `ChatWidget::set_transcript_ui_state`, +`BottomPane::set_transcript_ui_state`, and `ChatComposer::footer_props`, with footer text +assembled in `codex-rs/tui2/src/bottom_pane/footer.rs`. + +### 9.4 Exit transcript output + +`App::run` returns `session_lines` on `AppExitInfo` after flattening with +`App::build_transcript_lines` and converting to ANSI via `App::render_lines_to_ansi`. The CLI +prints those lines before the token usage and resume hints. + +## 10. Future Work and Open Questions + +This section collects design questions that follow naturally from the current model and are worth +explicit discussion before we commit to further UI changes. + +- **“Scroll mode” vs “live follow” UI.** + - We already distinguish “scrolled away from bottom” vs “following the latest output” in the + footer and scroll state. Do we need a more explicit “scroll mode vs live mode” affordance (e.g., + a dedicated indicator or toggle), or is the current behavior sufficient and adding more chrome + would be noise? + +- **Ephemeral scroll indicator.** + - For long sessions, a more visible sense of “where am I?” could help. One option is a minimalist + scrollbar that appears while the user is actively scrolling and fades out when idle. A full + “mini‑map” is probably too heavy for a TUI given the limited vertical space, but we could + imagine adding simple markers along the scrollbar to show where prior prompts occurred, or + where text search matches are, without trying to render a full preview of the buffer. + +- **Selection affordances.** + - Today, the primary hint that selection is active is the reversed text and the “Ctrl+Y copy + selection” footer text. Do we want an explicit “Selecting… (Esc to cancel)” status while a drag + is in progress, or would that be redundant/clutter for most users? + +- **Suspend banners in scrollback.** + - When printing history on suspend, should we also emit a small banner such as + `--- codex suspended; history up to here ---` to make those boundaries obvious in scrollback? + This would slightly increase noise but could make multi‑suspend sessions easier to read. + +- **Configuring suspend printing behavior.** + - The design already assumes that suspend‑time printing can be gated by config. Questions to + resolve: + - Should printing on suspend be on or off by default? + - Should we support multiple modes (e.g., “off”, “print all new cells”, “print streaming cell + tail only”) or keep it binary? + +- **Streaming duplication at the edges.** + - If we later choose to always re‑emit the “currently streaming” message when printing on suspend, + we would intentionally allow a small amount of duplication at the boundary of that message (for + example, its last line appearing twice across suspends). Is that acceptable if it improves the + readability of long streaming answers in scrollback, and should the ability to disable + suspend‑time printing be our escape hatch for users who care about exact de‑duplication?\*\*\* + +--- + diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index bb4c632447..032a228ec4 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -3,10 +3,13 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; +use crate::clipboard_copy; +use crate::custom_terminal::Frame; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; @@ -19,6 +22,9 @@ use crate::skill_error_prompt::run_skill_error_prompt; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; +use crate::wrapping::word_wrap_lines_borrowed; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; use codex_core::ConversationManager; @@ -46,9 +52,14 @@ use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::MouseButton; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::collections::BTreeMap; use std::path::Path; @@ -60,6 +71,7 @@ use std::thread; use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; +use unicode_width::UnicodeWidthStr; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; @@ -69,6 +81,12 @@ pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, pub update_action: Option, + /// ANSI-styled transcript lines to print after the TUI exits. + /// + /// These lines are rendered against the same width as the final TUI + /// viewport and include styling (colors, bold, etc.) so that scrollback + /// preserves the visual structure of the on-screen transcript. + pub session_lines: Vec, } impl From for codex_tui::AppExitInfo { @@ -268,6 +286,7 @@ async fn handle_model_migration_prompt_if_needed( token_usage: TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } } @@ -290,6 +309,12 @@ pub(crate) struct App { pub(crate) transcript_cells: Vec>, + #[allow(dead_code)] + transcript_scroll: TranscriptScroll, + transcript_selection: TranscriptSelection, + transcript_view_top: usize, + transcript_total_lines: usize, + // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, pub(crate) deferred_history_lines: Vec>, @@ -314,6 +339,42 @@ pub(crate) struct App { skip_world_writable_scan_once: bool, } +/// Scroll state for the inline transcript viewport. +/// +/// This tracks whether the transcript is pinned to the latest line or anchored +/// at a specific cell/line pair so later viewport changes can implement +/// scrollback without losing the notion of "bottom". +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, Default)] +enum TranscriptScroll { + #[default] + ToBottom, + Scrolled { + cell_index: usize, + line_in_cell: usize, + }, +} +/// Content-relative selection within the inline transcript viewport. +/// +/// Selection endpoints are expressed in terms of flattened, wrapped transcript +/// line indices and columns, so the highlight tracks logical conversation +/// content even when the viewport scrolls or the terminal is resized. +#[derive(Debug, Clone, Copy, Default)] +struct TranscriptSelection { + anchor: Option, + head: Option, +} + +/// A single endpoint of a transcript selection. +/// +/// `line_index` is an index into the flattened wrapped transcript lines, and +/// `column` is a zero-based column offset within that visual line, counted from +/// the first content column to the right of the transcript gutter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TranscriptSelectionPoint { + line_index: usize, + column: u16, +} impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { @@ -433,6 +494,10 @@ impl App { file_search, enhanced_keys_supported, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -493,11 +558,25 @@ impl App { app.handle_tui_event(tui, event).await? } } {} + let width = tui.terminal.last_known_screen_size.width; + let session_lines = if width == 0 { + Vec::new() + } else { + let (lines, meta) = Self::build_transcript_lines(&app.transcript_cells, width); + let is_user_cell: Vec = app + .transcript_cells + .iter() + .map(|cell| cell.as_any().is::()) + .collect(); + Self::render_lines_to_ansi(&lines, &meta, &is_user_cell, width) + }; + tui.terminal.clear()?; Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), update_action: app.pending_update_action, + session_lines, }) } @@ -513,6 +592,9 @@ impl App { TuiEvent::Key(key_event) => { self.handle_key_event(tui, key_event).await; } + TuiEvent::Mouse(mouse_event) => { + self.handle_mouse_event(tui, mouse_event); + } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), // but tui-textarea expects \n. Normalize CR to LF. @@ -529,21 +611,880 @@ impl App { { return Ok(true); } - tui.draw( - self.chat_widget.desired_height(tui.terminal.size()?.width), - |frame| { - self.chat_widget.render(frame.area(), frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { - frame.set_cursor_position((x, y)); - } - }, - )?; + let cells = self.transcript_cells.clone(); + tui.draw(tui.terminal.size()?.height, |frame| { + let chat_height = self.chat_widget.desired_height(frame.area().width); + let chat_top = self.render_transcript_cells(frame, &cells, chat_height); + let chat_area = Rect { + x: frame.area().x, + y: chat_top, + width: frame.area().width, + height: chat_height.min( + frame + .area() + .height + .saturating_sub(chat_top.saturating_sub(frame.area().y)), + ), + }; + self.chat_widget.render(chat_area, frame.buffer); + let chat_bottom = chat_area.y.saturating_add(chat_area.height); + if chat_bottom < frame.area().bottom() { + Clear.render_ref( + Rect { + x: frame.area().x, + y: chat_bottom, + width: frame.area().width, + height: frame.area().bottom().saturating_sub(chat_bottom), + }, + frame.buffer, + ); + } + if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { + frame.set_cursor_position((x, y)); + } + })?; + let transcript_scrolled = + !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); + let selection_active = matches!( + (self.transcript_selection.anchor, self.transcript_selection.head), + (Some(a), Some(b)) if a != b + ); + let scroll_position = if self.transcript_total_lines == 0 { + None + } else { + Some(( + self.transcript_view_top.saturating_add(1), + self.transcript_total_lines, + )) + }; + self.chat_widget.set_transcript_ui_state( + transcript_scrolled, + selection_active, + scroll_position, + ); } } } Ok(true) } + pub(crate) fn render_transcript_cells( + &mut self, + frame: &mut Frame, + cells: &[Arc], + chat_height: u16, + ) -> u16 { + let area = frame.area(); + if area.width == 0 || area.height == 0 { + self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.bottom().saturating_sub(chat_height); + } + + let chat_height = chat_height.min(area.height); + let max_transcript_height = area.height.saturating_sub(chat_height); + if max_transcript_height == 0 { + self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.y; + } + + let transcript_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: max_transcript_height, + }; + + let (lines, meta) = Self::build_transcript_lines(cells, transcript_area.width); + if lines.is_empty() { + Clear.render_ref(transcript_area, frame.buffer); + self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.y; + } + + let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize); + if wrapped.is_empty() { + self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.y; + } + + let is_user_cell: Vec = cells + .iter() + .map(|c| c.as_any().is::()) + .collect(); + let base_opts: RtOptions<'_> = RtOptions::new(transcript_area.width.max(1) as usize); + let mut wrapped_is_user_row: Vec = Vec::with_capacity(wrapped.len()); + let mut first = true; + for (idx, line) in lines.iter().enumerate() { + let opts = if first { + base_opts.clone() + } else { + base_opts + .clone() + .initial_indent(base_opts.subsequent_indent.clone()) + }; + let seg_count = word_wrap_line(line, opts).len(); + let is_user_row = meta + .get(idx) + .and_then(Option::as_ref) + .map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false)) + .unwrap_or(false); + wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count)); + first = false; + } + + let total_lines = wrapped.len(); + self.transcript_total_lines = total_lines; + let max_visible = std::cmp::min(max_transcript_height as usize, total_lines); + let max_start = total_lines.saturating_sub(max_visible); + + let top_offset = match self.transcript_scroll { + TranscriptScroll::ToBottom => max_start, + TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + } => { + let mut anchor = None; + for (idx, entry) in meta.iter().enumerate() { + if let Some((ci, li)) = entry + && *ci == cell_index + && *li == line_in_cell + { + anchor = Some(idx); + break; + } + } + if let Some(idx) = anchor { + idx.min(max_start) + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + max_start + } + } + }; + self.transcript_view_top = top_offset; + + let transcript_visible_height = max_visible as u16; + let chat_top = if total_lines <= max_transcript_height as usize { + let gap = if transcript_visible_height == 0 { 0 } else { 1 }; + area.y + .saturating_add(transcript_visible_height) + .saturating_add(gap) + } else { + area.bottom().saturating_sub(chat_height) + }; + + let clear_height = chat_top.saturating_sub(area.y); + if clear_height > 0 { + Clear.render_ref( + Rect { + x: area.x, + y: area.y, + width: area.width, + height: clear_height, + }, + frame.buffer, + ); + } + + let transcript_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: transcript_visible_height, + }; + + for (row_index, line_index) in (top_offset..total_lines).enumerate() { + if row_index >= max_visible { + break; + } + + let y = transcript_area.y + row_index as u16; + let row_area = Rect { + x: transcript_area.x, + y, + width: transcript_area.width, + height: 1, + }; + + if wrapped_is_user_row + .get(line_index) + .copied() + .unwrap_or(false) + { + let base_style = crate::style::user_message_style(); + for x in row_area.x..row_area.right() { + let cell = &mut frame.buffer[(x, y)]; + let style = cell.style().patch(base_style); + cell.set_style(style); + } + } + + wrapped[line_index].render_ref(row_area, frame.buffer); + } + + self.apply_transcript_selection(transcript_area, frame.buffer); + chat_top + } + + /// Handle mouse interaction in the main transcript view. + /// + /// - Mouse wheel movement scrolls the conversation history by small, fixed increments, + /// independent of the terminal's own scrollback. + /// - Mouse clicks and drags adjust a text selection defined in terms of + /// flattened transcript lines and columns, so the selection is anchored + /// to the underlying content rather than absolute screen rows. + /// - When the user drags to extend a selection while the view is following the bottom + /// and a task is actively running (e.g., streaming a response), the scroll mode is + /// first converted into an anchored position so that ongoing updates no longer move + /// the viewport under the selection. A simple click without a drag does not change + /// scroll behavior. + fn handle_mouse_event( + &mut self, + tui: &mut tui::Tui, + mouse_event: crossterm::event::MouseEvent, + ) { + use crossterm::event::MouseEventKind; + + if self.overlay.is_some() { + return; + } + + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(width); + if chat_height >= height { + return; + } + + // Only handle events over the transcript area above the composer. + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return; + } + + let transcript_area = Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }; + let base_x = transcript_area.x.saturating_add(2); + let max_x = transcript_area.right().saturating_sub(1); + + let mut clamped_x = mouse_event.column; + let mut clamped_y = mouse_event.row; + + if clamped_y < transcript_area.y || clamped_y >= transcript_area.bottom() { + clamped_y = transcript_area.y; + } + if clamped_x < base_x { + clamped_x = base_x; + } + if clamped_x > max_x { + clamped_x = max_x; + } + + let streaming = self.chat_widget.is_task_running(); + + match mouse_event.kind { + MouseEventKind::ScrollUp => { + self.scroll_transcript( + tui, + -3, + transcript_area.height as usize, + transcript_area.width, + ); + } + MouseEventKind::ScrollDown => { + self.scroll_transcript( + tui, + 3, + transcript_area.height as usize, + transcript_area.width, + ); + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(point) = self.transcript_point_from_coordinates( + transcript_area, + base_x, + clamped_x, + clamped_y, + ) { + self.transcript_selection.anchor = Some(point); + self.transcript_selection.head = Some(point); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(anchor) = self.transcript_selection.anchor + && let Some(point) = self.transcript_point_from_coordinates( + transcript_area, + base_x, + clamped_x, + clamped_y, + ) + { + if streaming + && matches!(self.transcript_scroll, TranscriptScroll::ToBottom) + && point != anchor + { + self.lock_transcript_scroll_to_current_view( + transcript_area.height as usize, + transcript_area.width, + ); + } + self.transcript_selection.head = Some(point); + } + } + MouseEventKind::Up(MouseButton::Left) => { + if self.transcript_selection.anchor == self.transcript_selection.head { + self.transcript_selection = TranscriptSelection::default(); + } + } + _ => {} + } + } + + /// Scroll the transcript by a fixed number of visual lines. + /// + /// This is the shared implementation behind mouse wheel movement and PgUp/PgDn keys in + /// the main view. Scroll state is expressed in terms of transcript cells and their + /// internal line indices, so scrolling refers to logical conversation content and + /// remains stable even as wrapping or streaming causes visual reflows. + fn scroll_transcript( + &mut self, + tui: &mut tui::Tui, + delta_lines: i32, + visible_lines: usize, + width: u16, + ) { + if visible_lines == 0 { + return; + } + + let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width); + let total_lines = lines.len(); + if total_lines <= visible_lines { + self.transcript_scroll = TranscriptScroll::ToBottom; + return; + } + + let max_start = total_lines.saturating_sub(visible_lines); + + let current_top = match self.transcript_scroll { + TranscriptScroll::ToBottom => max_start, + TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + } => { + let mut anchor = None; + for (idx, entry) in meta.iter().enumerate() { + if let Some((ci, li)) = entry + && *ci == cell_index + && *li == line_in_cell + { + anchor = Some(idx); + break; + } + } + anchor.unwrap_or(max_start).min(max_start) + } + }; + + if delta_lines == 0 { + return; + } + + let new_top = if delta_lines < 0 { + current_top.saturating_sub(delta_lines.unsigned_abs() as usize) + } else { + current_top + .saturating_add(delta_lines as usize) + .min(max_start) + }; + + if new_top == max_start { + self.transcript_scroll = TranscriptScroll::ToBottom; + } else { + let anchor = meta.iter().skip(new_top).find_map(|entry| *entry); + if let Some((cell_index, line_in_cell)) = anchor { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } else if let Some(prev_idx) = (0..=new_top).rfind(|&idx| meta[idx].is_some()) { + if let Some((cell_index, line_in_cell)) = meta[prev_idx] { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + } + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + } + } + + tui.frame_requester().schedule_frame(); + } + + /// Convert a `ToBottom` (auto-follow) scroll state into a fixed anchor at the current view. + /// + /// When the user begins a mouse selection while new output is streaming in, the view + /// should stop auto-following the latest line so the selection stays on the intended + /// content. This helper inspects the flattened transcript at the given width, derives + /// a concrete position corresponding to the current top row, and switches into a scroll + /// mode that keeps that position stable until the user scrolls again. + fn lock_transcript_scroll_to_current_view(&mut self, visible_lines: usize, width: u16) { + if self.transcript_cells.is_empty() || visible_lines == 0 || width == 0 { + return; + } + + let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width); + if lines.is_empty() || meta.is_empty() { + return; + } + + let total_lines = lines.len(); + let max_visible = std::cmp::min(visible_lines, total_lines); + if max_visible == 0 { + return; + } + + let max_start = total_lines.saturating_sub(max_visible); + let top_offset = match self.transcript_scroll { + TranscriptScroll::ToBottom => max_start, + TranscriptScroll::Scrolled { .. } => { + // Already anchored; nothing to lock. + return; + } + }; + + let mut anchor = None; + if let Some((cell_index, line_in_cell)) = meta.iter().skip(top_offset).flatten().next() { + anchor = Some((*cell_index, *line_in_cell)); + } + if anchor.is_none() + && let Some((cell_index, line_in_cell)) = + meta[..top_offset].iter().rev().flatten().next() + { + anchor = Some((*cell_index, *line_in_cell)); + } + + if let Some((cell_index, line_in_cell)) = anchor { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } + } + + /// Build the flattened transcript lines for rendering, scrolling, and exit transcripts. + /// + /// Returns both the visible `Line` buffer and a parallel metadata vector + /// that maps each line back to its originating `(cell_index, line_in_cell)` + /// pair, or `None` for spacer lines. This allows the scroll state to anchor + /// to a specific history cell even as new content arrives or the viewport + /// size changes, and gives exit transcript renderers enough structure to + /// style user rows differently from agent rows. + fn build_transcript_lines( + cells: &[Arc], + width: u16, + ) -> (Vec>, Vec>) { + let mut lines: Vec> = Vec::new(); + let mut meta: Vec> = Vec::new(); + let mut has_emitted_lines = false; + + for (cell_index, cell) in cells.iter().enumerate() { + let cell_lines = cell.display_lines(width); + if cell_lines.is_empty() { + continue; + } + + if !cell.is_stream_continuation() { + if has_emitted_lines { + lines.push(Line::from("")); + meta.push(None); + } else { + has_emitted_lines = true; + } + } + + for (line_in_cell, line) in cell_lines.into_iter().enumerate() { + meta.push(Some((cell_index, line_in_cell))); + lines.push(line); + } + } + + (lines, meta) + } + + /// Render flattened transcript lines into ANSI strings suitable for + /// printing after the TUI exits. + /// + /// This helper mirrors the original TUI viewport behavior: + /// - Merges line-level style into each span so the ANSI output matches + /// the on-screen styling (e.g., blockquotes, lists). + /// - For user-authored rows, pads the background style out to the full + /// terminal width so prompts appear as solid blocks in scrollback. + /// - Streams spans through the shared vt100 writer so downstream tests + /// and tools see consistent escape sequences. + fn render_lines_to_ansi( + lines: &[Line<'static>], + meta: &[Option<(usize, usize)>], + is_user_cell: &[bool], + width: u16, + ) -> Vec { + lines + .iter() + .enumerate() + .map(|(idx, line)| { + let is_user_row = meta + .get(idx) + .and_then(|entry| entry.as_ref()) + .map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false)) + .unwrap_or(false); + + let mut merged_spans: Vec> = line + .spans + .iter() + .map(|span| ratatui::text::Span { + style: span.style.patch(line.style), + content: span.content.clone(), + }) + .collect(); + + if is_user_row && width > 0 { + let text: String = merged_spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + let text_width = UnicodeWidthStr::width(text.as_str()); + let total_width = usize::from(width); + if text_width < total_width { + let pad_len = total_width.saturating_sub(text_width); + if pad_len > 0 { + let pad_style = crate::style::user_message_style(); + merged_spans.push(ratatui::text::Span { + style: pad_style, + content: " ".repeat(pad_len).into(), + }); + } + } + } + + let mut buf: Vec = Vec::new(); + let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter()); + String::from_utf8(buf).unwrap_or_default() + }) + .collect() + } + + /// Apply the current transcript selection to the given buffer. + /// + /// The selection is defined in terms of flattened wrapped transcript line + /// indices and columns. This method maps those content-relative endpoints + /// into the currently visible viewport based on `transcript_view_top` and + /// `transcript_total_lines`, so the highlight moves with the content as the + /// user scrolls. + fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) { + let (anchor, head) = match ( + self.transcript_selection.anchor, + self.transcript_selection.head, + ) { + (Some(a), Some(h)) => (a, h), + _ => return, + }; + + if self.transcript_total_lines == 0 { + return; + } + + let base_x = area.x.saturating_add(2); + let max_x = area.right().saturating_sub(1); + + let mut start = anchor; + let mut end = head; + if (end.line_index < start.line_index) + || (end.line_index == start.line_index && end.column < start.column) + { + std::mem::swap(&mut start, &mut end); + } + + let visible_start = self.transcript_view_top; + let visible_end = self + .transcript_view_top + .saturating_add(area.height as usize) + .min(self.transcript_total_lines); + + for (row_index, line_index) in (visible_start..visible_end).enumerate() { + if line_index < start.line_index || line_index > end.line_index { + continue; + } + + let y = area.y + row_index as u16; + + let mut first_text_x = None; + let mut last_text_x = None; + for x in base_x..=max_x { + let cell = &buf[(x, y)]; + if cell.symbol() != " " { + if first_text_x.is_none() { + first_text_x = Some(x); + } + last_text_x = Some(x); + } + } + + let (text_start, text_end) = match (first_text_x, last_text_x) { + // Treat indentation spaces as part of the selectable region by + // starting from the first content column to the right of the + // transcript gutter, but still clamp to the last non-space + // glyph so trailing padding is not included. + (Some(_), Some(e)) => (base_x, e), + _ => continue, + }; + + let line_start_col = if line_index == start.line_index { + start.column + } else { + 0 + }; + let line_end_col = if line_index == end.line_index { + end.column + } else { + max_x.saturating_sub(base_x) + }; + + let row_sel_start = base_x.saturating_add(line_start_col); + let row_sel_end = base_x.saturating_add(line_end_col).min(max_x); + + if row_sel_start > row_sel_end { + continue; + } + + let from_x = row_sel_start.max(text_start); + let to_x = row_sel_end.min(text_end); + + if from_x > to_x { + continue; + } + + for x in from_x..=to_x { + let cell = &mut buf[(x, y)]; + let style = cell.style(); + cell.set_style(style.add_modifier(ratatui::style::Modifier::REVERSED)); + } + } + } + + /// Copy the currently selected transcript region to the system clipboard. + /// + /// The selection is defined in terms of flattened wrapped transcript line + /// indices and columns, and this method reconstructs the same wrapped + /// transcript used for on-screen rendering so the copied text closely + /// matches the highlighted region. + fn copy_transcript_selection(&mut self, tui: &tui::Tui) { + let (anchor, head) = match ( + self.transcript_selection.anchor, + self.transcript_selection.head, + ) { + (Some(a), Some(h)) if a != h => (a, h), + _ => return, + }; + + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(width); + if chat_height >= height { + return; + } + + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return; + } + + let transcript_area = Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }; + + let cells = self.transcript_cells.clone(); + let (lines, _) = Self::build_transcript_lines(&cells, transcript_area.width); + if lines.is_empty() { + return; + } + + let wrapped = crate::wrapping::word_wrap_lines_borrowed( + &lines, + transcript_area.width.max(1) as usize, + ); + let total_lines = wrapped.len(); + if total_lines == 0 { + return; + } + + let max_visible = transcript_area.height as usize; + let visible_start = self + .transcript_view_top + .min(total_lines.saturating_sub(max_visible)); + let visible_end = std::cmp::min(visible_start + max_visible, total_lines); + + let mut buf = Buffer::empty(transcript_area); + Clear.render_ref(transcript_area, &mut buf); + + for (row_index, line_index) in (visible_start..visible_end).enumerate() { + let row_area = Rect { + x: transcript_area.x, + y: transcript_area.y + row_index as u16, + width: transcript_area.width, + height: 1, + }; + wrapped[line_index].render_ref(row_area, &mut buf); + } + + let base_x = transcript_area.x.saturating_add(2); + let max_x = transcript_area.right().saturating_sub(1); + + let mut start = anchor; + let mut end = head; + if (end.line_index < start.line_index) + || (end.line_index == start.line_index && end.column < start.column) + { + std::mem::swap(&mut start, &mut end); + } + + let mut lines_out: Vec = Vec::new(); + + for (row_index, line_index) in (visible_start..visible_end).enumerate() { + if line_index < start.line_index || line_index > end.line_index { + continue; + } + + let y = transcript_area.y + row_index as u16; + + let line_start_col = if line_index == start.line_index { + start.column + } else { + 0 + }; + let line_end_col = if line_index == end.line_index { + end.column + } else { + max_x.saturating_sub(base_x) + }; + + let row_sel_start = base_x.saturating_add(line_start_col); + let row_sel_end = base_x.saturating_add(line_end_col).min(max_x); + + if row_sel_start > row_sel_end { + continue; + } + + let mut first_text_x = None; + let mut last_text_x = None; + for x in base_x..=max_x { + let cell = &buf[(x, y)]; + if cell.symbol() != " " { + if first_text_x.is_none() { + first_text_x = Some(x); + } + last_text_x = Some(x); + } + } + + let (text_start, text_end) = match (first_text_x, last_text_x) { + // Treat indentation spaces as part of the copyable region by + // starting from the first content column to the right of the + // transcript gutter, but still clamp to the last non-space + // glyph so trailing padding is not included. + (Some(_), Some(e)) => (base_x, e), + _ => { + lines_out.push(String::new()); + continue; + } + }; + + let from_x = row_sel_start.max(text_start); + let to_x = row_sel_end.min(text_end); + if from_x > to_x { + continue; + } + + let mut line_text = String::new(); + for x in from_x..=to_x { + let cell = &buf[(x, y)]; + let symbol = cell.symbol(); + if !symbol.is_empty() { + line_text.push_str(symbol); + } + } + + lines_out.push(line_text); + } + + if lines_out.is_empty() { + return; + } + + let text = lines_out.join("\n"); + if let Err(err) = clipboard_copy::copy_text(text) { + tracing::error!(error = %err, "failed to copy selection to clipboard"); + } + } + + /// Map a mouse position in the transcript area to a content-relative + /// selection point, if there is transcript content to select. + fn transcript_point_from_coordinates( + &self, + transcript_area: Rect, + base_x: u16, + x: u16, + y: u16, + ) -> Option { + if self.transcript_total_lines == 0 { + return None; + } + + let mut row_index = y.saturating_sub(transcript_area.y); + if row_index >= transcript_area.height { + if transcript_area.height == 0 { + return None; + } + row_index = transcript_area.height.saturating_sub(1); + } + + let max_line = self.transcript_total_lines.saturating_sub(1); + let line_index = self + .transcript_view_top + .saturating_add(usize::from(row_index)) + .min(max_line); + let column = x.saturating_sub(base_x); + + Some(TranscriptSelectionPoint { line_index, column }) + } + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { let model_family = self .server @@ -655,8 +1596,8 @@ impl App { } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_cell(cell.clone()); + if let Some(Overlay::Transcript(transcript)) = &mut self.overlay { + transcript.insert_cell(cell.clone()); tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); @@ -674,8 +1615,6 @@ impl App { } if self.overlay.is_some() { self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); } } } @@ -1139,6 +2078,83 @@ impl App { self.chat_widget.handle_key_event(key_event); } } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.copy_transcript_selection(tui); + } + KeyEvent { + code: KeyCode::PageUp, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width > 0 && height > 0 { + let chat_height = self.chat_widget.desired_height(width); + if chat_height < height { + let transcript_height = height.saturating_sub(chat_height); + if transcript_height > 0 { + let delta = -i32::from(transcript_height); + self.scroll_transcript( + tui, + delta, + usize::from(transcript_height), + width, + ); + } + } + } + } + KeyEvent { + code: KeyCode::PageDown, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width > 0 && height > 0 { + let chat_height = self.chat_widget.desired_height(width); + if chat_height < height { + let transcript_height = height.saturating_sub(chat_height); + if transcript_height > 0 { + let delta = i32::from(transcript_height); + self.scroll_transcript( + tui, + delta, + usize::from(transcript_height), + width, + ); + } + } + } + } + KeyEvent { + code: KeyCode::Home, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + if !self.transcript_cells.is_empty() { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index: 0, + line_in_cell: 0, + }; + tui.frame_requester().schedule_frame(); + } + } + KeyEvent { + code: KeyCode::End, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.transcript_scroll = TranscriptScroll::ToBottom; + tui.frame_requester().schedule_frame(); + } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { code: KeyCode::Enter, @@ -1218,6 +2234,7 @@ mod tests { use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ConversationId; + use pretty_assertions::assert_eq; use ratatui::prelude::Line; use std::path::PathBuf; use std::sync::Arc; @@ -1245,6 +2262,10 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -1285,6 +2306,10 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -1444,6 +2469,68 @@ mod tests { assert_eq!(prefill, "follow-up (edited)"); } + #[test] + fn transcript_selection_moves_with_scroll() { + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + + let mut app = make_test_app(); + app.transcript_total_lines = 3; + + let area = Rect { + x: 0, + y: 0, + width: 10, + height: 2, + }; + + // Anchor selection to logical line 1, columns 2..4. + app.transcript_selection = TranscriptSelection { + anchor: Some(TranscriptSelectionPoint { + line_index: 1, + column: 2, + }), + head: Some(TranscriptSelectionPoint { + line_index: 1, + column: 4, + }), + }; + + // First render: top of view is line 0, so line 1 maps to the second row. + app.transcript_view_top = 0; + let mut buf = Buffer::empty(area); + for x in 2..area.width { + buf[(x, 0)].set_symbol("A"); + buf[(x, 1)].set_symbol("B"); + } + + app.apply_transcript_selection(area, &mut buf); + + // No selection should be applied to the first row when the view is anchored at the top. + for x in 0..area.width { + let cell = &buf[(x, 0)]; + assert!(cell.style().add_modifier.is_empty()); + } + + // After scrolling down by one line, the same logical line should now be + // rendered on the first row, and the highlight should move with it. + app.transcript_view_top = 1; + let mut buf_scrolled = Buffer::empty(area); + for x in 2..area.width { + buf_scrolled[(x, 0)].set_symbol("B"); + buf_scrolled[(x, 1)].set_symbol("C"); + } + + app.apply_transcript_selection(area, &mut buf_scrolled); + + // After scrolling, the selection should now be applied on the first row rather than the + // second. + for x in 0..area.width { + let cell = &buf_scrolled[(x, 1)]; + assert!(cell.style().add_modifier.is_empty()); + } + } + #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels(); @@ -1485,6 +2572,19 @@ mod tests { assert!(session_summary(TokenUsage::default(), None).is_none()); } + #[test] + fn render_lines_to_ansi_pads_user_rows_to_full_width() { + let line: Line<'static> = Line::from("hi"); + let lines = vec![line]; + let meta = vec![Some((0usize, 0usize))]; + let is_user_cell = vec![true]; + let width: u16 = 10; + + let rendered = App::render_lines_to_ansi(&lines, &meta, &is_user_cell, width); + assert_eq!(rendered.len(), 1); + assert!(rendered[0].contains("hi")); + } + #[test] fn session_summary_includes_resume_hint() { let usage = TokenUsage { diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 919866b00c..7ca10c6d24 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -118,6 +118,9 @@ pub(crate) struct ChatComposer { footer_hint_override: Option>, context_window_percent: Option, context_window_used_tokens: Option, + transcript_scrolled: bool, + transcript_selection_active: bool, + transcript_scroll_position: Option<(usize, usize)>, skills: Option>, dismissed_skill_popup_token: Option, } @@ -166,6 +169,9 @@ impl ChatComposer { footer_hint_override: None, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, skills: None, dismissed_skill_popup_token: None, }; @@ -1531,6 +1537,9 @@ impl ChatComposer { is_task_running: self.is_task_running, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, + transcript_scrolled: self.transcript_scrolled, + transcript_selection_active: self.transcript_selection_active, + transcript_scroll_position: self.transcript_scroll_position, } } @@ -1551,6 +1560,23 @@ impl ChatComposer { .map(|items| if items.is_empty() { 0 } else { 1 }) } + /// Update the footer's view of transcript scroll state for the inline viewport. + /// + /// This state is derived from the main `App`'s transcript viewport and passed + /// through the bottom pane so the footer can indicate when the transcript is + /// scrolled away from the bottom, whether a selection is active, and the + /// current `(visible_top, total)` position. + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { + self.transcript_scrolled = scrolled; + self.transcript_selection_active = selection_active; + self.transcript_scroll_position = scroll_position; + } + fn sync_popups(&mut self) { let file_token = Self::current_at_token(&self.textarea); let skill_token = self.current_skill_token(); diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index d47ffec98b..7f2b4e6285 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -22,6 +22,9 @@ pub(crate) struct FooterProps { pub(crate) is_task_running: bool, pub(crate) context_window_percent: Option, pub(crate) context_window_used_tokens: Option, + pub(crate) transcript_scrolled: bool, + pub(crate) transcript_selection_active: bool, + pub(crate) transcript_scroll_position: Option<(usize, usize)>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -94,6 +97,27 @@ fn footer_lines(props: FooterProps) -> Vec> { key_hint::plain(KeyCode::Char('?')).into(), " for shortcuts".dim(), ]); + if props.transcript_scrolled { + line.push_span(" · ".dim()); + line.push_span(key_hint::plain(KeyCode::PageUp)); + line.push_span("/"); + line.push_span(key_hint::plain(KeyCode::PageDown)); + line.push_span(" scroll".dim()); + line.push_span(" · ".dim()); + line.push_span(key_hint::plain(KeyCode::Home)); + line.push_span("/"); + line.push_span(key_hint::plain(KeyCode::End)); + line.push_span(" jump".dim()); + if let Some((current, total)) = props.transcript_scroll_position { + line.push_span(" · ".dim()); + line.push_span(Span::from(format!("{current}/{total}")).dim()); + } + } + if props.transcript_selection_active { + line.push_span(" · ".dim()); + line.push_span(key_hint::ctrl(KeyCode::Char('y'))); + line.push_span(" copy selection".dim()); + } vec![line] } FooterMode::ShortcutOverlay => { @@ -440,6 +464,24 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_transcript_scrolled_and_selection", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + transcript_scrolled: true, + transcript_selection_active: true, + transcript_scroll_position: Some((3, 42)), }, ); @@ -452,6 +494,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -464,6 +509,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -476,6 +524,9 @@ mod tests { is_task_running: true, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -488,6 +539,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -500,6 +554,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -512,6 +569,9 @@ mod tests { is_task_running: true, context_window_percent: Some(72), context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -524,6 +584,9 @@ mod tests { is_task_running: false, context_window_percent: None, context_window_used_tokens: Some(123_456), + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, }, ); } diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 8516687284..fbab5e14a2 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -381,6 +381,17 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { + self.composer + .set_transcript_ui_state(scrolled, selection_active, scroll_position); + self.request_redraw(); + } + /// Show a generic list selection view with the provided items. pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap new file mode 100644 index 0000000000..ee592efa26 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts · pgup/pgdn scroll · home/end jump · 3/42 " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 7dec1ae542..5461bea729 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -3073,6 +3073,30 @@ impl ChatWidget { pub(crate) fn clear_esc_backtrack_hint(&mut self) { self.bottom_pane.clear_esc_backtrack_hint(); } + + /// Return true when the bottom pane currently has an active task. + /// + /// This is used by the viewport to decide when mouse selections should + /// disengage auto-follow behavior while responses are streaming. + pub(crate) fn is_task_running(&self) -> bool { + self.bottom_pane.is_task_running() + } + + /// Inform the bottom pane about the current transcript scroll state. + /// + /// This is used by the footer to surface when the inline transcript is + /// scrolled away from the bottom and to display the current + /// `(visible_top, total)` scroll position alongside other shortcuts. + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { + self.bottom_pane + .set_transcript_ui_state(scrolled, selection_active, scroll_position); + } + /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { // Record outbound operation for session replay fidelity. diff --git a/codex-rs/tui2/src/clipboard_copy.rs b/codex-rs/tui2/src/clipboard_copy.rs new file mode 100644 index 0000000000..76718704e5 --- /dev/null +++ b/codex-rs/tui2/src/clipboard_copy.rs @@ -0,0 +1,79 @@ +use tracing::error; + +#[derive(Debug)] +pub enum ClipboardError { + ClipboardUnavailable(String), + WriteFailed(String), +} + +impl std::fmt::Display for ClipboardError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClipboardError::ClipboardUnavailable(msg) => { + write!(f, "clipboard unavailable: {msg}") + } + ClipboardError::WriteFailed(msg) => write!(f, "failed to write to clipboard: {msg}"), + } + } +} + +impl std::error::Error for ClipboardError {} + +pub trait ClipboardManager { + fn set_text(&mut self, text: String) -> Result<(), ClipboardError>; +} + +#[cfg(not(target_os = "android"))] +pub struct ArboardClipboardManager { + inner: Option, +} + +#[cfg(not(target_os = "android"))] +impl ArboardClipboardManager { + pub fn new() -> Self { + match arboard::Clipboard::new() { + Ok(cb) => Self { inner: Some(cb) }, + Err(err) => { + error!(error = %err, "failed to initialize clipboard"); + Self { inner: None } + } + } + } +} + +#[cfg(not(target_os = "android"))] +impl ClipboardManager for ArboardClipboardManager { + fn set_text(&mut self, text: String) -> Result<(), ClipboardError> { + let Some(cb) = &mut self.inner else { + return Err(ClipboardError::ClipboardUnavailable( + "clipboard is not available in this environment".to_string(), + )); + }; + cb.set_text(text) + .map_err(|e| ClipboardError::WriteFailed(e.to_string())) + } +} + +#[cfg(target_os = "android")] +pub struct ArboardClipboardManager; + +#[cfg(target_os = "android")] +impl ArboardClipboardManager { + pub fn new() -> Self { + ArboardClipboardManager + } +} + +#[cfg(target_os = "android")] +impl ClipboardManager for ArboardClipboardManager { + fn set_text(&mut self, _text: String) -> Result<(), ClipboardError> { + Err(ClipboardError::ClipboardUnavailable( + "clipboard text copy is unsupported on Android".to_string(), + )) + } +} + +pub fn copy_text(text: String) -> Result<(), ClipboardError> { + let mut manager = ArboardClipboardManager::new(); + manager.set_text(text) +} diff --git a/codex-rs/tui2/src/insert_history.rs b/codex-rs/tui2/src/insert_history.rs index 36ef47da5e..e0e2731678 100644 --- a/codex-rs/tui2/src/insert_history.rs +++ b/codex-rs/tui2/src/insert_history.rs @@ -241,7 +241,7 @@ impl ModifierDiff { } } -fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> +pub(crate) fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> where I: IntoIterator>, { diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index a0c034c146..7587779234 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -40,6 +40,7 @@ mod ascii_animation; mod bottom_pane; mod chatwidget; mod cli; +mod clipboard_copy; mod clipboard_paste; mod color; pub mod custom_terminal; @@ -369,6 +370,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: Some(action), + session_lines: Vec::new(), }); } } @@ -408,6 +410,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly @@ -443,6 +446,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } } @@ -481,6 +485,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } other => other, @@ -491,6 +496,12 @@ async fn run_ratatui_app( let Cli { prompt, images, .. } = cli; + // Run the main chat + transcript UI on the terminal's alternate screen so + // the entire viewport can be used without polluting normal scrollback. This + // mirrors the behavior of the legacy TUI but keeps inline mode available + // for smaller prompts like onboarding and model migration. + let _ = tui.enter_alt_screen(); + let app_result = App::run( &mut tui, auth_manager, @@ -504,7 +515,17 @@ async fn run_ratatui_app( ) .await; + let _ = tui.leave_alt_screen(); restore(); + if let Ok(exit_info) = &app_result { + let mut stdout = std::io::stdout(); + for line in exit_info.session_lines.iter() { + let _ = writeln!(stdout, "{line}"); + } + if !exit_info.session_lines.is_empty() { + let _ = writeln!(stdout); + } + } // Mark the end of the recorded session. session_log::log_session_end(); // ignore error when collecting usage – report underlying error instead diff --git a/codex-rs/tui2/src/model_migration.rs b/codex-rs/tui2/src/model_migration.rs index fa59b8fcc9..90cd4b2b37 100644 --- a/codex-rs/tui2/src/model_migration.rs +++ b/codex-rs/tui2/src/model_migration.rs @@ -114,6 +114,7 @@ pub(crate) async fn run_model_migration_prompt( if let Some(event) = events.next().await { match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Mouse(_) => {} TuiEvent::Paste(_) => {} TuiEvent::Draw => { let _ = alt.tui.draw(u16::MAX, |frame| { diff --git a/codex-rs/tui2/src/onboarding/onboarding_screen.rs b/codex-rs/tui2/src/onboarding/onboarding_screen.rs index 14999b2229..3ba2619a87 100644 --- a/codex-rs/tui2/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui2/src/onboarding/onboarding_screen.rs @@ -393,6 +393,7 @@ pub(crate) async fn run_onboarding_app( while !onboarding_screen.is_done() { if let Some(event) = tui_events.next().await { match event { + TuiEvent::Mouse(_) => {} TuiEvent::Key(key_event) => { onboarding_screen.handle_key_event(key_event); } diff --git a/codex-rs/tui2/src/pager_overlay.rs b/codex-rs/tui2/src/pager_overlay.rs index 46aaba8644..06a7cad467 100644 --- a/codex-rs/tui2/src/pager_overlay.rs +++ b/codex-rs/tui2/src/pager_overlay.rs @@ -14,6 +14,8 @@ use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; @@ -283,6 +285,24 @@ impl PagerView { Ok(()) } + fn handle_mouse_scroll(&mut self, tui: &mut tui::Tui, event: MouseEvent) -> Result<()> { + let step: usize = 3; + match event.kind { + MouseEventKind::ScrollUp => { + self.scroll_offset = self.scroll_offset.saturating_sub(step); + } + MouseEventKind::ScrollDown => { + self.scroll_offset = self.scroll_offset.saturating_add(step); + } + _ => { + return Ok(()); + } + } + tui.frame_requester() + .schedule_frame_in(Duration::from_millis(16)); + Ok(()) + } + /// Returns the height of one page in content rows. /// /// Prefers the last rendered content height (excluding header/footer chrome); @@ -506,6 +526,7 @@ impl TranscriptOverlay { } other => self.view.handle_key_event(tui, other), }, + TuiEvent::Mouse(mouse_event) => self.view.handle_mouse_scroll(tui, mouse_event), TuiEvent::Draw => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); @@ -565,6 +586,7 @@ impl StaticOverlay { } other => self.view.handle_key_event(tui, other), }, + TuiEvent::Mouse(mouse_event) => self.view.handle_mouse_scroll(tui, mouse_event), TuiEvent::Draw => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); diff --git a/codex-rs/tui2/src/skill_error_prompt.rs b/codex-rs/tui2/src/skill_error_prompt.rs index 33d3b5dce1..41aaefa4c9 100644 --- a/codex-rs/tui2/src/skill_error_prompt.rs +++ b/codex-rs/tui2/src/skill_error_prompt.rs @@ -58,6 +58,7 @@ pub(crate) async fn run_skill_error_prompt( if let Some(event) = events.next().await { match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Mouse(_) => {} TuiEvent::Paste(_) => {} TuiEvent::Draw => { let _ = alt.tui.draw(u16::MAX, |frame| { diff --git a/codex-rs/tui2/src/tui.rs b/codex-rs/tui2/src/tui.rs index f9566da908..807c807c87 100644 --- a/codex-rs/tui2/src/tui.rs +++ b/codex-rs/tui2/src/tui.rs @@ -1,4 +1,3 @@ -use std::fmt; use std::io::IsTerminal; use std::io::Result; use std::io::Stdout; @@ -10,12 +9,13 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use crossterm::Command; use crossterm::SynchronizedUpdate; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; +use crossterm::event::DisableMouseCapture; use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableFocusChange; +use crossterm::event::EnableMouseCapture; use crossterm::event::Event; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; @@ -24,7 +24,6 @@ use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; -use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; @@ -74,56 +73,18 @@ pub fn set_modes() -> Result<()> { ); let _ = execute!(stdout(), EnableFocusChange); + // Enable application mouse mode so scroll events are delivered as + // Mouse events instead of arrow keys. + let _ = execute!(stdout(), EnableMouseCapture); Ok(()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct EnableAlternateScroll; - -impl Command for EnableAlternateScroll { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b[?1007h") - } - - #[cfg(windows)] - fn execute_winapi(&self) -> Result<()> { - Err(std::io::Error::other( - "tried to execute EnableAlternateScroll using WinAPI; use ANSI instead", - )) - } - - #[cfg(windows)] - fn is_ansi_code_supported(&self) -> bool { - true - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct DisableAlternateScroll; - -impl Command for DisableAlternateScroll { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b[?1007l") - } - - #[cfg(windows)] - fn execute_winapi(&self) -> Result<()> { - Err(std::io::Error::other( - "tried to execute DisableAlternateScroll using WinAPI; use ANSI instead", - )) - } - - #[cfg(windows)] - fn is_ansi_code_supported(&self) -> bool { - true - } -} - /// Restore the terminal to its original state. /// Inverse of `set_modes`. pub fn restore() -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + let _ = execute!(stdout(), DisableMouseCapture); execute!(stdout(), DisableBracketedPaste)?; let _ = execute!(stdout(), DisableFocusChange); disable_raw_mode()?; @@ -161,6 +122,7 @@ pub enum TuiEvent { Key(KeyEvent), Paste(String), Draw, + Mouse(crossterm::event::MouseEvent), } pub struct Tui { @@ -297,6 +259,9 @@ impl Tui { Event::Paste(pasted) => { yield TuiEvent::Paste(pasted); } + Event::Mouse(mouse_event) => { + yield TuiEvent::Mouse(mouse_event); + } Event::FocusGained => { terminal_focused.store(true, Ordering::Relaxed); crate::terminal_palette::requery_default_colors(); @@ -305,7 +270,6 @@ impl Tui { Event::FocusLost => { terminal_focused.store(false, Ordering::Relaxed); } - _ => {} } } Some(Err(_)) | None => { @@ -341,8 +305,6 @@ impl Tui { /// inline viewport for restoration when leaving. pub fn enter_alt_screen(&mut self) -> Result<()> { let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen); - // Enable "alternate scroll" so terminals may translate wheel to arrows - let _ = execute!(self.terminal.backend_mut(), EnableAlternateScroll); if let Ok(size) = self.terminal.size() { self.alt_saved_viewport = Some(self.terminal.viewport_area); self.terminal.set_viewport_area(ratatui::layout::Rect::new( @@ -359,8 +321,6 @@ impl Tui { /// Leave alternate screen and restore the previously saved inline viewport, if any. pub fn leave_alt_screen(&mut self) -> Result<()> { - // Disable alternate scroll when leaving alt-screen - let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll); let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); if let Some(saved) = self.alt_saved_viewport.take() { self.terminal.set_viewport_area(saved); @@ -404,30 +364,13 @@ impl Tui { let size = terminal.size()?; - let mut area = terminal.viewport_area; - area.height = height.min(size.height); - area.width = size.width; - // If the viewport has expanded, scroll everything else up to make room. - if area.bottom() > size.height { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), area.bottom() - size.height)?; - area.y = size.height - area.height; - } + let area = Rect::new(0, 0, size.width, height.min(size.height)); if area != terminal.viewport_area { // TODO(nornagon): probably this could be collapsed with the clear + set_viewport_area above. terminal.clear()?; terminal.set_viewport_area(area); } - if !self.pending_history_lines.is_empty() { - crate::insert_history::insert_history_lines( - terminal, - self.pending_history_lines.clone(), - )?; - self.pending_history_lines.clear(); - } - // Update the y position for suspending so Ctrl-Z can place the cursor correctly. #[cfg(unix)] { diff --git a/codex-rs/tui2/src/tui/job_control.rs b/codex-rs/tui2/src/tui/job_control.rs index 3680419489..0796e93f67 100644 --- a/codex-rs/tui2/src/tui/job_control.rs +++ b/codex-rs/tui2/src/tui/job_control.rs @@ -18,8 +18,6 @@ use ratatui::layout::Rect; use crate::key_hint; -use super::DisableAlternateScroll; -use super::EnableAlternateScroll; use super::Terminal; pub const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z')); @@ -63,8 +61,7 @@ impl SuspendContext { /// - Trigger SIGTSTP so the process can be resumed and continue drawing with the saved state. pub(crate) fn suspend(&self, alt_screen_active: &Arc) -> Result<()> { if alt_screen_active.load(Ordering::Relaxed) { - // Leave alt-screen so the terminal returns to the normal buffer while suspended; also turn off alt-scroll. - let _ = execute!(stdout(), DisableAlternateScroll); + // Leave alt-screen so the terminal returns to the normal buffer while suspended. let _ = execute!(stdout(), LeaveAlternateScreen); self.set_resume_action(ResumeAction::RestoreAlt); } else { @@ -157,11 +154,10 @@ impl PreparedResumeAction { match self { PreparedResumeAction::RealignViewport(area) => { terminal.set_viewport_area(area); + terminal.clear()?; } PreparedResumeAction::RestoreAltScreen => { execute!(terminal.backend_mut(), EnterAlternateScreen)?; - // Enable "alternate scroll" so terminals may translate wheel to arrows - execute!(terminal.backend_mut(), EnableAlternateScroll)?; if let Ok(size) = terminal.size() { terminal.set_viewport_area(Rect::new(0, 0, size.width, size.height)); terminal.clear()?;