From c0ed5342ecb9f873d587d5b6d0bf02a2e635505b Mon Sep 17 00:00:00 2001 From: liliwilson Date: Thu, 28 May 2026 23:18:21 -0700 Subject: [PATCH 01/11] Add codex plugin. --- .../plugin_manager/codex.rs | 241 ++++++++++++++++-- .../plugin_manager/codex_tests.rs | 208 ++++++++++++++- .../cli_agent_sessions/plugin_manager/mod.rs | 6 +- 3 files changed, 423 insertions(+), 32 deletions(-) diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs index 57ecd6312d..f14fd97898 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs @@ -1,23 +1,127 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::LazyLock; +use std::{env, fs, io}; use async_trait::async_trait; +use serde_json::Value; -use super::{CliAgentPluginManager, PluginInstructionStep, PluginInstructions}; +use super::{ + compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError, + PluginInstructionStep, PluginInstructions, +}; +use crate::terminal::model::session::LocalCommandExecutor; +use crate::terminal::shell::ShellType; -pub(super) struct CodexPluginManager; +const PLUGIN_KEY: &str = "warp@codex-warp"; +const MARKETPLACE_REPO: &str = "warpdotdev/codex-warp"; +const MARKETPLACE_NAME: &str = "codex-warp"; + +const PLATFORM_PLUGIN_KEY: &str = "orchestration@codex-warp"; + +const CODEX_CONFIG_DIR: &str = ".codex"; +const CODEX_HOME_ENV: &str = "CODEX_HOME"; + +// Keep in sync with the plugin version in warpdotdev/codex-warp. +const MINIMUM_PLUGIN_VERSION: &str = "0.4.0"; + +pub(super) struct CodexPluginManager { + executor: LocalCommandExecutor, + path_env_var: Option, +} + +impl CodexPluginManager { + pub(super) fn new( + shell_path: Option, + shell_type: Option, + path_env_var: Option, + ) -> Self { + let shell_type = shell_type.unwrap_or(ShellType::Bash); + Self { + executor: LocalCommandExecutor::new(shell_path, shell_type), + path_env_var, + } + } + + async fn run_logged(&self, args: &[&str], log: &mut String) -> Result<(), PluginInstallError> { + let env_vars = self + .path_env_var + .as_deref() + .map(|path| HashMap::from([("PATH".to_owned(), path.to_owned())])); + run_cli_command_logged("codex", args, &self.executor, env_vars, log).await + } +} #[async_trait] impl CliAgentPluginManager for CodexPluginManager { fn minimum_plugin_version(&self) -> &'static str { - "0.0.0" + MINIMUM_PLUGIN_VERSION } fn can_auto_install(&self) -> bool { - false + true + } + + fn is_installed(&self) -> bool { + let Ok(codex_dir) = codex_home_dir() else { + return false; + }; + check_installed(&codex_dir) + } + + fn needs_update(&self) -> bool { + let Ok(codex_dir) = codex_home_dir() else { + return false; + }; + match installed_version(&codex_dir) { + Some(v) => compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt(), + None => check_installed(&codex_dir), + } + } + + async fn install(&self) -> Result<(), PluginInstallError> { + let mut log = String::new(); + self.run_logged( + &["plugin", "marketplace", "add", MARKETPLACE_REPO], + &mut log, + ) + .await?; + self.run_logged(&["plugin", "add", PLUGIN_KEY], &mut log) + .await?; + Ok(()) + } + + async fn update(&self) -> Result<(), PluginInstallError> { + let mut log = String::new(); + self.run_logged( + &["plugin", "marketplace", "upgrade", MARKETPLACE_NAME], + &mut log, + ) + .await?; + self.run_logged(&["plugin", "add", PLUGIN_KEY], &mut log) + .await?; + + let still_outdated = codex_home_dir() + .ok() + .and_then(|dir| installed_version(&dir)) + .map(|v| compare_versions(&v, MINIMUM_PLUGIN_VERSION).is_lt()) + .unwrap_or(true); + if still_outdated { + log.push_str("Post-update version check: plugin is still outdated\n"); + return Err(PluginInstallError { + message: "Plugin update did not take effect".to_owned(), + log, + }); + } + Ok(()) + } + + fn install_success_message(&self) -> &'static str { + "Warp plugin installed. Please restart Codex to activate." } - fn supports_update(&self) -> bool { - false + fn update_success_message(&self) -> &'static str { + "Warp plugin updated. Please restart Codex to activate." } fn install_instructions(&self) -> &'static PluginInstructions { @@ -25,39 +129,126 @@ impl CliAgentPluginManager for CodexPluginManager { } fn update_instructions(&self) -> &'static PluginInstructions { - &EMPTY_INSTRUCTIONS + &UPDATE_INSTRUCTIONS + } + + async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> { + let mut log = String::new(); + self.run_logged( + &["plugin", "marketplace", "add", MARKETPLACE_REPO], + &mut log, + ) + .await?; + self.run_logged(&["plugin", "add", PLATFORM_PLUGIN_KEY], &mut log) + .await?; + Ok(()) } } -static INSTALL_INSTRUCTIONS: LazyLock = LazyLock::new(|| { - PluginInstructions { - title: "Enable Warp Notifications for Codex", - subtitle: "Update Codex to the latest version, then enable in-focus notifications so Warp can display them while you work.", +static INSTALL_INSTRUCTIONS: LazyLock = LazyLock::new(|| PluginInstructions { + title: "Install Warp Plugin for Codex", + subtitle: "Run the following commands, then restart Codex.", steps: &[ PluginInstructionStep { - description: "Update Codex to the latest version.", - command: "", - executable: false, - link: Some("https://developers.openai.com/codex/cli#upgrade"), + description: "Add the Warp plugin marketplace repository", + command: "codex plugin marketplace add warpdotdev/codex-warp", + executable: true, + link: None, }, PluginInstructionStep { - description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:", - command: "[tui]\nnotification_condition = \"always\"", - executable: false, + description: "Install the Warp plugin", + command: "codex plugin add warp@codex-warp", + executable: true, link: None, }, ], - post_install_notes: &["Restart Codex to apply the changes."], -} + post_install_notes: &["Restart Codex to activate the plugin."], }); -static EMPTY_INSTRUCTIONS: LazyLock = LazyLock::new(|| PluginInstructions { - title: "", - subtitle: "", - steps: &[], - post_install_notes: &[], +static UPDATE_INSTRUCTIONS: LazyLock = LazyLock::new(|| PluginInstructions { + title: "Update Warp Plugin for Codex", + subtitle: "Run the following commands, then restart Codex.", + steps: &[ + PluginInstructionStep { + description: "Upgrade the Warp plugin marketplace", + command: "codex plugin marketplace upgrade codex-warp", + executable: true, + link: None, + }, + PluginInstructionStep { + description: "Reinstall the Warp plugin", + command: "codex plugin add warp@codex-warp", + executable: true, + link: None, + }, + ], + post_install_notes: &["Restart Codex to activate the update."], }); +fn check_installed(codex_dir: &Path) -> bool { + let config_path = codex_dir.join("config.toml"); + let Ok(contents) = fs::read_to_string(config_path) else { + return false; + }; + let Ok(parsed) = contents.parse::() else { + return false; + }; + parsed + .get("plugins") + .and_then(|plugins| plugins.get(PLUGIN_KEY)) + .and_then(|plugin| plugin.get("enabled")) + .and_then(|enabled| enabled.as_bool()) + .unwrap_or(false) +} + +/// Reads the latest cached Warp plugin version, if present. +fn installed_version(codex_dir: &Path) -> Option { + let cache_dir = codex_dir + .join("plugins") + .join("cache") + .join(MARKETPLACE_NAME) + .join("warp"); + let entries = fs::read_dir(cache_dir).ok()?; + let mut latest: Option = None; + for entry in entries.flatten() { + let manifest_path = entry.path().join(".codex-plugin").join("plugin.json"); + let Ok(contents) = fs::read_to_string(manifest_path) else { + continue; + }; + let Ok(parsed) = serde_json::from_str::(&contents) else { + continue; + }; + let Some(version) = parsed.get("version").and_then(|v| v.as_str()) else { + continue; + }; + if latest + .as_deref() + .map(|current| compare_versions(version, current).is_gt()) + .unwrap_or(true) + { + latest = Some(version.to_owned()); + } + } + latest +} + +/// Checks `CODEX_HOME` first, falls back to `~/.codex`. +fn codex_home_dir() -> io::Result { + if let Ok(codex_home) = env::var(CODEX_HOME_ENV) { + if !codex_home.is_empty() { + return Ok(PathBuf::from(codex_home)); + } + } + dirs::home_dir() + .map(|home| home.join(CODEX_CONFIG_DIR)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "could not determine home directory", + ) + }) +} + #[cfg(test)] #[path = "codex_tests.rs"] mod tests; diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs index 13e740b5f4..a19d3f7986 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs @@ -1,19 +1,215 @@ use super::CodexPluginManager; -use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager; +use crate::terminal::cli_agent_sessions::plugin_manager::{ + compare_versions, CliAgentPluginManager, +}; +use std::fs; #[test] -fn can_auto_install_is_false() { - assert!(!CodexPluginManager.can_auto_install()); +fn can_auto_install_is_true() { + assert!(CodexPluginManager::new(None, None, None).can_auto_install()); } #[test] -fn does_not_support_update() { - assert!(!CodexPluginManager.supports_update()); +fn supports_update() { + assert!(CodexPluginManager::new(None, None, None).supports_update()); +} + +#[test] +fn minimum_version() { + assert_eq!( + CodexPluginManager::new(None, None, None).minimum_plugin_version(), + "0.4.0" + ); } #[test] fn install_instructions_has_steps() { - let instructions = CodexPluginManager.install_instructions(); + let instructions = CodexPluginManager::new(None, None, None).install_instructions(); + assert_eq!( + instructions.steps[0].command, + "codex plugin marketplace add warpdotdev/codex-warp" + ); + assert_eq!( + instructions.steps[1].command, + "codex plugin add warp@codex-warp" + ); + assert!(!instructions.steps.is_empty()); + assert!(!instructions.title.is_empty()); +} + +#[test] +fn update_instructions_has_steps() { + let instructions = CodexPluginManager::new(None, None, None).update_instructions(); + assert_eq!( + instructions.steps[0].command, + "codex plugin marketplace upgrade codex-warp" + ); + assert_eq!( + instructions.steps[1].command, + "codex plugin add warp@codex-warp" + ); assert!(!instructions.steps.is_empty()); assert!(!instructions.title.is_empty()); } + +#[test] +fn installed_when_config_enabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("config.toml"), + "[plugins.\"warp@codex-warp\"]\nenabled = true\n", + ) + .unwrap(); + + assert!(super::check_installed(dir.path())); +} + +#[test] +fn not_installed_when_config_disabled() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("config.toml"), + "[plugins.\"warp@codex-warp\"]\nenabled = false\n", + ) + .unwrap(); + + assert!(!super::check_installed(dir.path())); +} + +#[test] +fn not_installed_when_config_missing() { + let dir = tempfile::tempdir().unwrap(); + assert!(!super::check_installed(dir.path())); +} + +#[test] +fn not_installed_when_config_invalid() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("config.toml"), "not toml").unwrap(); + + assert!(!super::check_installed(dir.path())); +} + +#[test] +fn installed_version_returns_latest_manifest_version() { + let dir = tempfile::tempdir().unwrap(); + write_manifest(dir.path(), "0.9.0"); + write_manifest(dir.path(), "1.5.0"); + write_manifest(dir.path(), "1.2.0"); + + assert_eq!( + super::installed_version(dir.path()).as_deref(), + Some("1.5.0") + ); +} + +#[test] +fn installed_version_returns_none_when_cache_missing() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(super::installed_version(dir.path()), None); +} + +#[test] +fn installed_version_returns_none_when_manifest_has_no_version() { + let dir = tempfile::tempdir().unwrap(); + let manifest_dir = dir + .path() + .join("plugins/cache/codex-warp/warp/1.0.0/.codex-plugin"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::write(manifest_dir.join("plugin.json"), "{\"name\":\"warp\"}").unwrap(); + + assert_eq!(super::installed_version(dir.path()), None); +} + +#[test] +fn needs_update_logic_true_when_version_outdated() { + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.2.0"); + + let needs_update = match super::installed_version(dir.path()) { + Some(v) => compare_versions(&v, "0.4.0").is_lt(), + None => super::check_installed(dir.path()), + }; + assert!(needs_update); +} + +#[test] +fn needs_update_logic_true_when_installed_without_manifest() { + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + + let needs_update = match super::installed_version(dir.path()) { + Some(v) => compare_versions(&v, "0.4.0").is_lt(), + None => super::check_installed(dir.path()), + }; + assert!(needs_update); +} + +#[test] +fn needs_update_logic_false_when_version_current() { + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.4.0"); + + let needs_update = match super::installed_version(dir.path()) { + Some(v) => compare_versions(&v, "0.4.0").is_lt(), + None => super::check_installed(dir.path()), + }; + assert!(!needs_update); +} + +#[test] +#[serial_test::serial] +fn is_installed_via_trait_with_codex_home_env() { + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).is_installed(); + std::env::remove_var("CODEX_HOME"); + + assert!(result); +} + +#[test] +#[serial_test::serial] +fn needs_update_via_trait_with_codex_home_env() { + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.2.0"); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).needs_update(); + std::env::remove_var("CODEX_HOME"); + + assert!(result); +} + +fn write_enabled_config(dir: &std::path::Path) { + fs::write( + dir.join("config.toml"), + "[plugins.\"warp@codex-warp\"]\nenabled = true\n", + ) + .unwrap(); +} + +fn write_manifest(dir: &std::path::Path, version: &str) { + let manifest_dir = dir + .join("plugins") + .join("cache") + .join("codex-warp") + .join("warp") + .join(version) + .join(".codex-plugin"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::write( + manifest_dir.join("plugin.json"), + serde_json::json!({ + "name": "warp", + "version": version + }) + .to_string(), + ) + .unwrap(); +} diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs index fe5974a723..6df73c8240 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs @@ -266,7 +266,11 @@ pub(crate) fn plugin_manager_for_with_shell( if FeatureFlag::CodexNotifications.is_enabled() && FeatureFlag::HOANotifications.is_enabled() => { - Some(Box::new(CodexPluginManager)) + Some(Box::new(CodexPluginManager::new( + shell_path, + shell_type, + path_env_var, + ))) } CLIAgent::Gemini if FeatureFlag::GeminiNotifications.is_enabled() From 029e5387dce4ce482c0c0b46a2c64630c2e79b5c Mon Sep 17 00:00:00 2001 From: liliwilson Date: Thu, 28 May 2026 23:32:47 -0700 Subject: [PATCH 02/11] Add FF and also make sure we only use OSC9 when _not_ using plugin --- app/Cargo.toml | 1 + app/src/features.rs | 2 + .../cli_agent_sessions/listener/mod.rs | 48 +++--- .../cli_agent_sessions/listener/mod_tests.rs | 45 +++++- .../plugin_manager/codex.rs | 149 +++++++++++++----- .../plugin_manager/codex_tests.rs | 77 +++++++++ app/src/terminal/view.rs | 5 + crates/warp_features/src/lib.rs | 5 + 8 files changed, 269 insertions(+), 63 deletions(-) diff --git a/app/Cargo.toml b/app/Cargo.toml index 61ac931b54..ef71ec49d0 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -997,6 +997,7 @@ hoa_onboarding_flow = [] git_operations_in_code_review = [] hoa_remote_control = [] codex_notifications = [] +codex_plugin = [] cloud_mode_setup_v2 = ["cloud_mode"] cloud_mode_input_v2 = ["cloud_mode"] configurable_context_window = [] diff --git a/app/src/features.rs b/app/src/features.rs index 4cd1956fe8..28764b72e0 100644 --- a/app/src/features.rs +++ b/app/src/features.rs @@ -479,6 +479,8 @@ fn enabled_features() -> HashSet { FeatureFlag::HOARemoteControl, #[cfg(feature = "codex_notifications")] FeatureFlag::CodexNotifications, + #[cfg(feature = "codex_plugin")] + FeatureFlag::CodexPlugin, #[cfg(feature = "trim_trailing_blank_lines")] FeatureFlag::TrimTrailingBlankLines, #[cfg(feature = "cloud_mode_setup_v2")] diff --git a/app/src/terminal/cli_agent_sessions/listener/mod.rs b/app/src/terminal/cli_agent_sessions/listener/mod.rs index c5c0642845..ef7b42ee62 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod.rs @@ -1,6 +1,7 @@ use warpui::{EntityId, ModelContext, ModelHandle, SingletonEntity}; use super::{CLIAgentEvent, CLIAgentSessionsModel}; +use crate::features::FeatureFlag; use crate::terminal::cli_agent_sessions::event::{ parse_event, CLIAgentEventPayload, CLIAgentEventType, }; @@ -15,7 +16,7 @@ trait CLIAgentSessionHandler { /// The default implementation delegates to the structured JSON parser /// (`parse_event`); agents with non-JSON notification formats (e.g. Codex /// OSC 9 plain text) should override this. - fn try_parse(&self, title: Option<&str>, body: &str) -> Option { + fn try_parse(&mut self, title: Option<&str>, body: &str) -> Option { parse_event(title, body) } @@ -64,7 +65,7 @@ fn create_handler(agent: &CLIAgent) -> Option> { | CLIAgent::Gemini | CLIAgent::Auggie | CLIAgent::Pi => Some(Box::new(DefaultSessionListener)), - CLIAgent::Codex => Some(Box::new(CodexSessionHandler)), + CLIAgent::Codex => Some(Box::new(CodexSessionHandler::default())), CLIAgent::Hermes | CLIAgent::Amp | CLIAgent::Droid @@ -91,15 +92,16 @@ impl CLIAgentSessionHandler for DefaultSessionListener { } } -/// Codex-specific handler that parses plain-text OSC 9 desktop notifications -/// into CLI agent events. +/// Codex-specific handler that supports both native OSC 9 fallback and structured plugin events. /// /// Codex sends notifications via OSC 9 (`\x1b]9;message\x07`) with -/// human-readable text. Since there's no way to distinguish notification types -/// from the raw text, all OSC 9 notifications are treated as `Stop` (success). -/// The notification body becomes the event's `query` so it surfaces as the -/// notification title in the UI. -struct CodexSessionHandler; +/// human-readable text. Since there's no way to distinguish notification types from the raw text, +/// OSC 9 fallback notifications are treated as `Stop` (success). +#[derive(Default)] +struct CodexSessionHandler { + /// Whether we are using a plugin with OSC777 events or falling back to OSC9. + structured_plugin_active: bool, +} impl CodexSessionHandler { /// Parse a plain-text OSC 9 notification body into a `CLIAgentEvent`. @@ -126,17 +128,25 @@ impl CodexSessionHandler { } impl CLIAgentSessionHandler for CodexSessionHandler { - /// Codex sends plain-text OSC 9 notifications (title = `None`) instead of - /// the structured OSC 777 JSON used by Claude Code / OpenCode. - fn try_parse(&self, title: Option<&str>, body: &str) -> Option { - // If the notification carries the structured sentinel, try the normal - // JSON parser first (future-proofing in case Codex adds plugin - // support later). - if let Some(parsed) = parse_event(title, body) { - return Some(parsed); + /// Before Codex enabled support for hooks, we relied on OSC 9 to trigger notifications in Warp. + /// Here, we try to parse an OSC 777 event if we can, and set the plugin on CodexSessionHandler to + /// be true after the first OSC 777 notification we receive. This lets us ignore OSC 9 notifications + /// if we are working with a client that is using the new plugin, but keeps them intact for legacy + /// clients. + fn try_parse(&mut self, title: Option<&str>, body: &str) -> Option { + if let Some(event) = parse_event(title, body) { + if event.agent == CLIAgent::Codex { + if !FeatureFlag::CodexPlugin.is_enabled() { + return None; + } + self.structured_plugin_active = true; + return Some(event); + } + return None; } - // OSC 9 notifications have no title. - if title.is_some() { + // OSC 9 notifications have no title. Also skip OSC 9 processing if the plugin is active, otherwise + // we'd process both OSC 777 and OSC 9 notifications. + if title.is_some() || self.structured_plugin_active { return None; } Self::parse_osc9_text(body) diff --git a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs index cbeb2fec4d..651ab43c73 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs @@ -1,5 +1,7 @@ use super::*; -use crate::terminal::cli_agent_sessions::event::CLIAgentEventType; +use crate::terminal::cli_agent_sessions::event::{ + CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL, +}; #[test] fn codex_parses_any_text_as_stop() { @@ -40,7 +42,7 @@ fn codex_ignores_empty_body() { #[test] fn codex_try_parse_ignores_titled_notifications() { - let handler = CodexSessionHandler; + let mut handler = CodexSessionHandler::default(); assert!(handler .try_parse(Some("some-title"), "Agent turn complete") .is_none()); @@ -48,11 +50,48 @@ fn codex_try_parse_ignores_titled_notifications() { #[test] fn codex_try_parse_handles_osc9() { - let handler = CodexSessionHandler; + let mut handler = CodexSessionHandler::default(); let event = handler.try_parse(None, "Agent turn complete").unwrap(); assert_eq!(event.event, CLIAgentEventType::Stop); } +#[test] +fn codex_try_parse_ignores_osc9_after_structured_event() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); + let mut handler = CodexSessionHandler::default(); + let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#; + + let event = handler + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body) + .unwrap(); + + assert_eq!(event.event, CLIAgentEventType::PermissionRequest); + assert!(handler.try_parse(None, "Agent turn complete").is_none()); +} + +#[test] +fn codex_try_parse_ignores_structured_event_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let mut handler = CodexSessionHandler::default(); + let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#; + + assert!(handler + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body) + .is_none()); + assert!(handler.try_parse(None, "Agent turn complete").is_some()); +} + +#[test] +fn codex_try_parse_ignores_other_structured_agents() { + let mut handler = CodexSessionHandler::default(); + let body = r#"{"v":1,"agent":"claude","event":"stop"}"#; + + assert!(handler + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body) + .is_none()); + assert!(handler.try_parse(None, "Agent turn complete").is_some()); +} + #[test] fn auggie_is_supported() { assert!(is_agent_supported(&CLIAgent::Auggie)); diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs index f14fd97898..ee9fdb9f83 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs @@ -10,6 +10,7 @@ use super::{ compare_versions, run_cli_command_logged, CliAgentPluginManager, PluginInstallError, PluginInstructionStep, PluginInstructions, }; +use crate::features::FeatureFlag; use crate::terminal::model::session::LocalCommandExecutor; use crate::terminal::shell::ShellType; @@ -55,14 +56,21 @@ impl CodexPluginManager { #[async_trait] impl CliAgentPluginManager for CodexPluginManager { fn minimum_plugin_version(&self) -> &'static str { - MINIMUM_PLUGIN_VERSION + if FeatureFlag::CodexPlugin.is_enabled() { + MINIMUM_PLUGIN_VERSION + } else { + "0.0.0" + } } fn can_auto_install(&self) -> bool { - true + FeatureFlag::CodexPlugin.is_enabled() } fn is_installed(&self) -> bool { + if !FeatureFlag::CodexPlugin.is_enabled() { + return false; + } let Ok(codex_dir) = codex_home_dir() else { return false; }; @@ -70,6 +78,9 @@ impl CliAgentPluginManager for CodexPluginManager { } fn needs_update(&self) -> bool { + if !FeatureFlag::CodexPlugin.is_enabled() { + return false; + } let Ok(codex_dir) = codex_home_dir() else { return false; }; @@ -80,6 +91,9 @@ impl CliAgentPluginManager for CodexPluginManager { } async fn install(&self) -> Result<(), PluginInstallError> { + if !FeatureFlag::CodexPlugin.is_enabled() { + return Ok(()); + } let mut log = String::new(); self.run_logged( &["plugin", "marketplace", "add", MARKETPLACE_REPO], @@ -92,6 +106,9 @@ impl CliAgentPluginManager for CodexPluginManager { } async fn update(&self) -> Result<(), PluginInstallError> { + if !FeatureFlag::CodexPlugin.is_enabled() { + return Ok(()); + } let mut log = String::new(); self.run_logged( &["plugin", "marketplace", "upgrade", MARKETPLACE_NAME], @@ -117,7 +134,11 @@ impl CliAgentPluginManager for CodexPluginManager { } fn install_success_message(&self) -> &'static str { - "Warp plugin installed. Please restart Codex to activate." + if FeatureFlag::CodexPlugin.is_enabled() { + "Warp plugin installed. Please restart Codex to activate." + } else { + "Warp notifications enabled. Please restart Codex to activate." + } } fn update_success_message(&self) -> &'static str { @@ -125,14 +146,29 @@ impl CliAgentPluginManager for CodexPluginManager { } fn install_instructions(&self) -> &'static PluginInstructions { - &INSTALL_INSTRUCTIONS + if FeatureFlag::CodexPlugin.is_enabled() { + &PLUGIN_INSTALL_INSTRUCTIONS + } else { + &NATIVE_INSTALL_INSTRUCTIONS + } } fn update_instructions(&self) -> &'static PluginInstructions { - &UPDATE_INSTRUCTIONS + if FeatureFlag::CodexPlugin.is_enabled() { + &PLUGIN_UPDATE_INSTRUCTIONS + } else { + &EMPTY_INSTRUCTIONS + } + } + + fn supports_update(&self) -> bool { + FeatureFlag::CodexPlugin.is_enabled() } async fn install_platform_plugin(&self) -> Result<(), PluginInstallError> { + if !FeatureFlag::CodexPlugin.is_enabled() { + return Ok(()); + } let mut log = String::new(); self.run_logged( &["plugin", "marketplace", "add", MARKETPLACE_REPO], @@ -145,46 +181,77 @@ impl CliAgentPluginManager for CodexPluginManager { } } -static INSTALL_INSTRUCTIONS: LazyLock = LazyLock::new(|| PluginInstructions { - title: "Install Warp Plugin for Codex", - subtitle: "Run the following commands, then restart Codex.", - steps: &[ - PluginInstructionStep { - description: "Add the Warp plugin marketplace repository", - command: "codex plugin marketplace add warpdotdev/codex-warp", - executable: true, - link: None, - }, - PluginInstructionStep { - description: "Install the Warp plugin", - command: "codex plugin add warp@codex-warp", - executable: true, - link: None, - }, - ], - post_install_notes: &["Restart Codex to activate the plugin."], +static PLUGIN_INSTALL_INSTRUCTIONS: LazyLock = + LazyLock::new(|| PluginInstructions { + title: "Install Warp Plugin for Codex", + subtitle: "Run the following commands, then restart Codex.", + steps: &[ + PluginInstructionStep { + description: "Add the Warp plugin marketplace repository", + command: "codex plugin marketplace add warpdotdev/codex-warp", + executable: true, + link: None, + }, + PluginInstructionStep { + description: "Install the Warp plugin", + command: "codex plugin add warp@codex-warp", + executable: true, + link: None, + }, + ], + post_install_notes: &["Restart Codex to activate the plugin."], + }); + +static NATIVE_INSTALL_INSTRUCTIONS: LazyLock = LazyLock::new(|| { + PluginInstructions { + title: "Enable Warp Notifications for Codex", + subtitle: "Update Codex to the latest version, then enable in-focus notifications so Warp can display them while you work.", + steps: &[ + PluginInstructionStep { + description: "Update Codex to the latest version.", + command: "", + executable: false, + link: Some("https://developers.openai.com/codex/cli#upgrade"), + }, + PluginInstructionStep { + description: "Set the notification condition to \"always\" in your Codex config. Open or create ~/.codex/config.toml and add:", + command: "[tui]\nnotification_condition = \"always\"", + executable: false, + link: None, + }, + ], + post_install_notes: &["Restart Codex to apply the changes."], + } }); -static UPDATE_INSTRUCTIONS: LazyLock = LazyLock::new(|| PluginInstructions { - title: "Update Warp Plugin for Codex", - subtitle: "Run the following commands, then restart Codex.", - steps: &[ - PluginInstructionStep { - description: "Upgrade the Warp plugin marketplace", - command: "codex plugin marketplace upgrade codex-warp", - executable: true, - link: None, - }, - PluginInstructionStep { - description: "Reinstall the Warp plugin", - command: "codex plugin add warp@codex-warp", - executable: true, - link: None, - }, - ], - post_install_notes: &["Restart Codex to activate the update."], +static EMPTY_INSTRUCTIONS: LazyLock = LazyLock::new(|| PluginInstructions { + title: "", + subtitle: "", + steps: &[], + post_install_notes: &[], }); +static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock = + LazyLock::new(|| PluginInstructions { + title: "Update Warp Plugin for Codex", + subtitle: "Run the following commands, then restart Codex.", + steps: &[ + PluginInstructionStep { + description: "Upgrade the Warp plugin marketplace", + command: "codex plugin marketplace upgrade codex-warp", + executable: true, + link: None, + }, + PluginInstructionStep { + description: "Reinstall the Warp plugin", + command: "codex plugin add warp@codex-warp", + executable: true, + link: None, + }, + ], + post_install_notes: &["Restart Codex to activate the update."], + }); + fn check_installed(codex_dir: &Path) -> bool { let config_path = codex_dir.join("config.toml"); let Ok(contents) = fs::read_to_string(config_path) else { diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs index a19d3f7986..8e28bb71ed 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs @@ -1,4 +1,5 @@ use super::CodexPluginManager; +use crate::features::FeatureFlag; use crate::terminal::cli_agent_sessions::plugin_manager::{ compare_versions, CliAgentPluginManager, }; @@ -6,24 +7,60 @@ use std::fs; #[test] fn can_auto_install_is_true() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); assert!(CodexPluginManager::new(None, None, None).can_auto_install()); } +#[test] +fn can_auto_install_is_false_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + assert!(!CodexPluginManager::new(None, None, None).can_auto_install()); +} + +#[test] +fn install_instructions_are_native_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let instructions = CodexPluginManager::new(None, None, None).install_instructions(); + assert_eq!(instructions.title, "Enable Warp Notifications for Codex"); + assert_eq!( + instructions.steps[1].command, + "[tui]\nnotification_condition = \"always\"" + ); +} + #[test] fn supports_update() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); assert!(CodexPluginManager::new(None, None, None).supports_update()); } +#[test] +fn does_not_support_update_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + assert!(!CodexPluginManager::new(None, None, None).supports_update()); +} + #[test] fn minimum_version() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); assert_eq!( CodexPluginManager::new(None, None, None).minimum_plugin_version(), "0.4.0" ); } +#[test] +fn minimum_version_is_zero_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + assert_eq!( + CodexPluginManager::new(None, None, None).minimum_plugin_version(), + "0.0.0" + ); +} + #[test] fn install_instructions_has_steps() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); let instructions = CodexPluginManager::new(None, None, None).install_instructions(); assert_eq!( instructions.steps[0].command, @@ -39,6 +76,7 @@ fn install_instructions_has_steps() { #[test] fn update_instructions_has_steps() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); let instructions = CodexPluginManager::new(None, None, None).update_instructions(); assert_eq!( instructions.steps[0].command, @@ -52,6 +90,14 @@ fn update_instructions_has_steps() { assert!(!instructions.title.is_empty()); } +#[test] +fn update_instructions_are_empty_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let instructions = CodexPluginManager::new(None, None, None).update_instructions(); + assert!(instructions.steps.is_empty()); + assert!(instructions.title.is_empty()); +} + #[test] fn installed_when_config_enabled() { let dir = tempfile::tempdir().unwrap(); @@ -159,9 +205,24 @@ fn needs_update_logic_false_when_version_current() { assert!(!needs_update); } +#[test] +#[serial_test::serial] +fn is_not_installed_via_trait_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).is_installed(); + std::env::remove_var("CODEX_HOME"); + + assert!(!result); +} + #[test] #[serial_test::serial] fn is_installed_via_trait_with_codex_home_env() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); let dir = tempfile::tempdir().unwrap(); write_enabled_config(dir.path()); @@ -172,9 +233,25 @@ fn is_installed_via_trait_with_codex_home_env() { assert!(result); } +#[test] +#[serial_test::serial] +fn does_not_need_update_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); + let dir = tempfile::tempdir().unwrap(); + write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.2.0"); + + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).needs_update(); + std::env::remove_var("CODEX_HOME"); + + assert!(!result); +} + #[test] #[serial_test::serial] fn needs_update_via_trait_with_codex_home_env() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); let dir = tempfile::tempdir().unwrap(); write_enabled_config(dir.path()); write_manifest(dir.path(), "0.2.0"); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 5ab758738f..f192094993 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -12893,6 +12893,11 @@ impl TerminalView { if !is_agent_supported(¬ification.agent) { return; } + + if notification.agent == CLIAgent::Codex && !FeatureFlag::CodexPlugin.is_enabled() { + return; + } + if !self.register_cli_agent_listener_from_event(¬ification, ctx) { return; } diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 4baf95eb43..d7750a7fdb 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -789,6 +789,10 @@ pub enum FeatureFlag { /// Enables the install/update chip for the Codex Warp notification plugin. /// Requires HOANotifications to also be enabled. CodexNotifications, + + /// Enables the Codex Warp plugin marketplace integration. + /// When disabled, Codex uses native OSC9 notifications. + CodexPlugin, /// Enables the install/update chip for the Gemini CLI Warp extension. /// Requires HOANotifications to also be enabled. @@ -942,6 +946,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::EditableMarkdownMermaid, FeatureFlag::CodeReviewScrollPreservation, FeatureFlag::RememberFastForwardState, + FeatureFlag::CodexPlugin, FeatureFlag::GeminiNotifications, FeatureFlag::LocalDockerSandbox, #[cfg(not(windows))] From 240ed0fdb48c9faf5c4de4d8668b0f9537b9ce8c Mon Sep 17 00:00:00 2001 From: liliwilson Date: Tue, 2 Jun 2026 11:53:24 -0700 Subject: [PATCH 03/11] temp improvements --- .../agent_view/agent_input_footer/mod.rs | 5 +- .../cli_agent_sessions/listener/mod.rs | 9 ++- app/src/terminal/cli_agent_sessions/mod.rs | 32 +++++++-- .../plugin_manager/codex.rs | 20 +++++- .../plugin_manager/codex_tests.rs | 69 +++++++++---------- app/src/terminal/view.rs | 27 ++++---- crates/warp_features/src/lib.rs | 2 +- 7 files changed, 93 insertions(+), 71 deletions(-) diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index e07da2a85a..8221ecc05a 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -1084,10 +1084,9 @@ impl AgentInputFooter { let manager = plugin_manager_for(session.agent)?; let min_version = manager.minimum_plugin_version(); let chip_key = plugin_chip_key(session.agent.command_prefix(), &session.remote_host); - - // If the plugin is connected (listener present) and this agent supports + // If a structured plugin is connected and this agent supports // version-based updates, check the reported version. - if session.listener.is_some() && manager.supports_update() { + if session.has_structured_plugin() && manager.supports_update() { let needs_update = match &session.plugin_version { // No version reported = pre-versioning plugin, definitely outdated. None => true, diff --git a/app/src/terminal/cli_agent_sessions/listener/mod.rs b/app/src/terminal/cli_agent_sessions/listener/mod.rs index ef7b42ee62..12fdbd0bc0 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod.rs @@ -129,10 +129,9 @@ impl CodexSessionHandler { impl CLIAgentSessionHandler for CodexSessionHandler { /// Before Codex enabled support for hooks, we relied on OSC 9 to trigger notifications in Warp. - /// Here, we try to parse an OSC 777 event if we can, and set the plugin on CodexSessionHandler to - /// be true after the first OSC 777 notification we receive. This lets us ignore OSC 9 notifications - /// if we are working with a client that is using the new plugin, but keeps them intact for legacy - /// clients. + /// Here, we try to parse an OSC 777 event if we can, and remember when we've seen one. + /// This lets us ignore OSC 9 notifications if we are working with a client that is using + /// the new plugin, but keeps them intact for legacy clients. fn try_parse(&mut self, title: Option<&str>, body: &str) -> Option { if let Some(event) = parse_event(title, body) { if event.agent == CLIAgent::Codex { @@ -145,7 +144,7 @@ impl CLIAgentSessionHandler for CodexSessionHandler { return None; } // OSC 9 notifications have no title. Also skip OSC 9 processing if the plugin is active, otherwise - // we'd process both OSC 777 and OSC 9 notifications. + // we'd process both OSC 777 and OSC 9 notifications. if title.is_some() || self.structured_plugin_active { return None; } diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index b1d89e2da6..d4aced60e4 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -122,12 +122,12 @@ pub struct CLIAgentSession { pub input_state: CLIAgentInputState, /// Whether status-driven auto-toggle is enabled for this session. pub should_auto_toggle_input: bool, - /// Plugin-backed event listener, if the CLI agent plugin is installed. - /// `None` for sessions created by command detection alone. + /// Event listener for plugin-backed sessions or Codex OSC9 fallback. + /// `None` for non-Codex sessions created by command detection alone. /// Dropping this handle cleans up the listener's PTY event subscription. pub listener: Option>, - /// The plugin version reported by the `SessionStart` event. - /// `None` if the plugin predates version reporting or hasn't connected yet. + /// The plugin version reported by structured plugin events. + /// `None` if the plugin predates version reporting or Codex is using OSC9 fallback. pub plugin_version: Option, /// `None` when the session is local. /// `Some("user@hostname")` when running over SSH (warpified or legacy). @@ -146,6 +146,21 @@ impl CLIAgentSession { pub fn is_remote(&self) -> bool { self.remote_host.is_some() } + /// Whether this session is backed by a connected structured plugin (rich + /// OSC 777 events) rather than command detection or Codex's OSC 9 fallback. + /// Codex's structured plugin always reports a version on connect, so a + /// present `plugin_version` distinguishes it from the versionless OSC 9 + /// fallback. Other agents only ever connect via the structured plugin. + pub fn has_structured_plugin(&self) -> bool { + self.listener.is_some() + && (!matches!(self.agent, CLIAgent::Codex) || self.plugin_version.is_some()) + } + + pub fn supports_rich_status(&self) -> bool { + self.has_structured_plugin() + && (matches!(self.agent, CLIAgent::Codex) + || listener::agent_supports_rich_status(&self.agent)) + } /// Clears state populated by `PermissionRequest`. Called whenever the /// session leaves the permission flow (the user replied, a new prompt @@ -161,6 +176,9 @@ impl CLIAgentSession { /// Applies an event to this session, updating context and status. /// Returns the new status if it changed, or `None` if the event was irrelevant. fn apply_event(&mut self, event: &CLIAgentEvent) -> Option { + if let Some(plugin_version) = &event.payload.plugin_version { + self.plugin_version = Some(plugin_version.clone()); + } self.session_context.cwd = event.cwd.clone().or(self.session_context.cwd.take()); self.session_context.project = event .project @@ -216,7 +234,6 @@ impl CLIAgentSession { // This should not affect status — otherwise it would override Success after a Stop event. CLIAgentEventType::IdlePrompt => return None, CLIAgentEventType::SessionStart => { - self.plugin_version = event.payload.plugin_version.clone(); return None; } CLIAgentEventType::Unknown(_) => return None, @@ -403,6 +420,7 @@ impl CLIAgentSessionsModel { }; let event_type = &event.event; + let old_plugin_version = session.plugin_version.clone(); if let Some(new_status) = session.apply_event(event) { let agent = session.agent; ctx.emit(CLIAgentSessionsModelEvent::StatusChanged { @@ -418,7 +436,8 @@ impl CLIAgentSessionsModel { CLIAgentEventType::SessionStart | CLIAgentEventType::PromptSubmit | CLIAgentEventType::ToolComplete - ) { + ) || session.plugin_version != old_plugin_version + { ctx.emit(CLIAgentSessionsModelEvent::SessionUpdated { terminal_view_id, agent: session.agent, @@ -486,6 +505,7 @@ impl CLIAgentSessionsModel { ctx: &mut ModelContext, ) { let agent = session.agent; + // Close any open rich input before replacing, so subscribers can // restore input config before the session ends. self.close_input(terminal_view_id, false, ctx); diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs index ee9fdb9f83..d340e2cb83 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs @@ -110,8 +110,16 @@ impl CliAgentPluginManager for CodexPluginManager { return Ok(()); } let mut log = String::new(); + // Remove and re-add the marketplace to refresh the local snapshot, then + // reinstall the plugin. Mirrors the Claude update flow. + let _ = self + .run_logged( + &["plugin", "marketplace", "remove", MARKETPLACE_NAME], + &mut log, + ) + .await; self.run_logged( - &["plugin", "marketplace", "upgrade", MARKETPLACE_NAME], + &["plugin", "marketplace", "add", MARKETPLACE_REPO], &mut log, ) .await?; @@ -237,8 +245,14 @@ static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock = subtitle: "Run the following commands, then restart Codex.", steps: &[ PluginInstructionStep { - description: "Upgrade the Warp plugin marketplace", - command: "codex plugin marketplace upgrade codex-warp", + description: "Remove the existing marketplace (if present)", + command: "codex plugin marketplace remove codex-warp", + executable: true, + link: None, + }, + PluginInstructionStep { + description: "Re-add the marketplace", + command: "codex plugin marketplace add warpdotdev/codex-warp", executable: true, link: None, }, diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs index 8e28bb71ed..21f6099040 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs @@ -1,8 +1,6 @@ use super::CodexPluginManager; use crate::features::FeatureFlag; -use crate::terminal::cli_agent_sessions::plugin_manager::{ - compare_versions, CliAgentPluginManager, -}; +use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager; use std::fs; #[test] @@ -80,10 +78,14 @@ fn update_instructions_has_steps() { let instructions = CodexPluginManager::new(None, None, None).update_instructions(); assert_eq!( instructions.steps[0].command, - "codex plugin marketplace upgrade codex-warp" + "codex plugin marketplace remove codex-warp" ); assert_eq!( instructions.steps[1].command, + "codex plugin marketplace add warpdotdev/codex-warp" + ); + assert_eq!( + instructions.steps[2].command, "codex plugin add warp@codex-warp" ); assert!(!instructions.steps.is_empty()); @@ -168,52 +170,43 @@ fn installed_version_returns_none_when_manifest_has_no_version() { } #[test] -fn needs_update_logic_true_when_version_outdated() { +#[serial_test::serial] +fn is_not_installed_via_trait_without_codex_plugin() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(false); let dir = tempfile::tempdir().unwrap(); write_enabled_config(dir.path()); - write_manifest(dir.path(), "0.2.0"); - - let needs_update = match super::installed_version(dir.path()) { - Some(v) => compare_versions(&v, "0.4.0").is_lt(), - None => super::check_installed(dir.path()), - }; - assert!(needs_update); -} -#[test] -fn needs_update_logic_true_when_installed_without_manifest() { - let dir = tempfile::tempdir().unwrap(); - write_enabled_config(dir.path()); + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).is_installed(); + std::env::remove_var("CODEX_HOME"); - let needs_update = match super::installed_version(dir.path()) { - Some(v) => compare_versions(&v, "0.4.0").is_lt(), - None => super::check_installed(dir.path()), - }; - assert!(needs_update); + assert!(!result); } #[test] -fn needs_update_logic_false_when_version_current() { +#[serial_test::serial] +fn is_installed_via_trait_with_codex_home_env() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); let dir = tempfile::tempdir().unwrap(); write_enabled_config(dir.path()); - write_manifest(dir.path(), "0.4.0"); - let needs_update = match super::installed_version(dir.path()) { - Some(v) => compare_versions(&v, "0.4.0").is_lt(), - None => super::check_installed(dir.path()), - }; - assert!(!needs_update); + std::env::set_var("CODEX_HOME", dir.path()); + let result = CodexPluginManager::new(None, None, None).is_installed(); + std::env::remove_var("CODEX_HOME"); + + assert!(result); } #[test] #[serial_test::serial] -fn is_not_installed_via_trait_without_codex_plugin() { +fn does_not_need_update_without_codex_plugin() { let _guard = FeatureFlag::CodexPlugin.override_enabled(false); let dir = tempfile::tempdir().unwrap(); write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.2.0"); std::env::set_var("CODEX_HOME", dir.path()); - let result = CodexPluginManager::new(None, None, None).is_installed(); + let result = CodexPluginManager::new(None, None, None).needs_update(); std::env::remove_var("CODEX_HOME"); assert!(!result); @@ -221,13 +214,14 @@ fn is_not_installed_via_trait_without_codex_plugin() { #[test] #[serial_test::serial] -fn is_installed_via_trait_with_codex_home_env() { +fn needs_update_via_trait_with_codex_home_env() { let _guard = FeatureFlag::CodexPlugin.override_enabled(true); let dir = tempfile::tempdir().unwrap(); write_enabled_config(dir.path()); + write_manifest(dir.path(), "0.2.0"); std::env::set_var("CODEX_HOME", dir.path()); - let result = CodexPluginManager::new(None, None, None).is_installed(); + let result = CodexPluginManager::new(None, None, None).needs_update(); std::env::remove_var("CODEX_HOME"); assert!(result); @@ -235,11 +229,11 @@ fn is_installed_via_trait_with_codex_home_env() { #[test] #[serial_test::serial] -fn does_not_need_update_without_codex_plugin() { - let _guard = FeatureFlag::CodexPlugin.override_enabled(false); +fn does_not_need_update_via_trait_when_version_current() { + let _guard = FeatureFlag::CodexPlugin.override_enabled(true); let dir = tempfile::tempdir().unwrap(); write_enabled_config(dir.path()); - write_manifest(dir.path(), "0.2.0"); + write_manifest(dir.path(), "0.4.0"); std::env::set_var("CODEX_HOME", dir.path()); let result = CodexPluginManager::new(None, None, None).needs_update(); @@ -250,11 +244,10 @@ fn does_not_need_update_without_codex_plugin() { #[test] #[serial_test::serial] -fn needs_update_via_trait_with_codex_home_env() { +fn needs_update_via_trait_when_installed_without_manifest() { let _guard = FeatureFlag::CodexPlugin.override_enabled(true); let dir = tempfile::tempdir().unwrap(); write_enabled_config(dir.path()); - write_manifest(dir.path(), "0.2.0"); std::env::set_var("CODEX_HOME", dir.path()); let result = CodexPluginManager::new(None, None, None).needs_update(); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index f192094993..1b152f81c8 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -402,9 +402,7 @@ use crate::terminal::cli_agent_sessions::event::{ parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL, }; -use crate::terminal::cli_agent_sessions::listener::{ - agent_supports_rich_status, is_agent_supported, CLIAgentSessionListener, -}; +use crate::terminal::cli_agent_sessions::listener::{is_agent_supported, CLIAgentSessionListener}; #[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; use crate::terminal::cli_agent_sessions::{ @@ -11778,6 +11776,7 @@ impl TerminalView { let model = me.model.lock(); me.detect_cli_agent_from_model(&model, ctx) }; + let view_id = me.view_id; CLIAgentSessionsModel::handle(ctx).update( ctx, @@ -11820,6 +11819,7 @@ impl TerminalView { if matches!(detection, Some((CLIAgent::Codex, _))) { me.register_cli_agent_listener_without_session_start_event( CLIAgent::Codex, + false, ctx, ); } @@ -12893,7 +12893,7 @@ impl TerminalView { if !is_agent_supported(¬ification.agent) { return; } - + if notification.agent == CLIAgent::Codex && !FeatureFlag::CodexPlugin.is_enabled() { return; } @@ -12941,6 +12941,7 @@ impl TerminalView { let remote_host = self.active_session_remote_host(ctx); let should_auto_toggle_input = *AISettings::as_ref(ctx).auto_open_rich_input_on_cli_agent_start; + // Seed context from the event that caused registration before the // listener subscribes to future events. CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { @@ -12964,16 +12965,16 @@ impl TerminalView { fn register_cli_agent_listener_without_session_start_event( &mut self, agent: CLIAgent, + seed_plugin_version: bool, ctx: &mut ViewContext, ) { - // No SessionStart event in this path (mid-session install/update). - // Assume the just-installed plugin meets the minimum version for this agent - // so the update chip doesn't flash before the user runs /reload-plugins. #[cfg(not(target_family = "wasm"))] - let plugin_version = - plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned()); + let plugin_version = seed_plugin_version + .then(|| plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned())) + .flatten(); #[cfg(target_family = "wasm")] let plugin_version = None; + let notification = CLIAgentEvent { v: 1, agent, @@ -13127,11 +13128,7 @@ impl TerminalView { { let should_auto_toggle_input = CLIAgentSessionsModel::as_ref(ctx) .session(self.view_id) - .is_some_and(|s| { - s.listener.is_some() - && s.should_auto_toggle_input - && agent_supports_rich_status(&s.agent) - }); + .is_some_and(|s| s.supports_rich_status() && s.should_auto_toggle_input); if should_auto_toggle_input { match status { CLIAgentSessionStatus::Blocked { .. } => { @@ -21360,7 +21357,7 @@ impl TerminalView { self.enter_environment_setup_selector(repos.clone(), ctx); } InputEvent::RegisterPluginListener(agent) => { - self.register_cli_agent_listener_without_session_start_event(*agent, ctx); + self.register_cli_agent_listener_without_session_start_event(*agent, true, ctx); } #[cfg(not(target_family = "wasm"))] InputEvent::OpenPluginInstructionsPane(agent, kind) => { diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index d7750a7fdb..6cd7a2765e 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -789,7 +789,7 @@ pub enum FeatureFlag { /// Enables the install/update chip for the Codex Warp notification plugin. /// Requires HOANotifications to also be enabled. CodexNotifications, - + /// Enables the Codex Warp plugin marketplace integration. /// When disabled, Codex uses native OSC9 notifications. CodexPlugin, From e6d61922db9dbf2a4d72a177bd37ad76dbfbd044 Mon Sep 17 00:00:00 2001 From: liliwilson Date: Tue, 2 Jun 2026 20:58:13 -0700 Subject: [PATCH 04/11] temp --- .../agent_view/agent_input_footer/mod.rs | 14 +++++--------- .../cli_agent_sessions/listener/mod.rs | 18 ------------------ .../cli_agent_sessions/listener/mod_tests.rs | 8 -------- app/src/terminal/cli_agent_sessions/mod.rs | 7 +++++-- app/src/terminal/view/use_agent_footer/mod.rs | 9 +++------ app/src/ui_components/agent_icon.rs | 3 +-- app/src/workspace/view/vertical_tabs.rs | 8 ++------ 7 files changed, 16 insertions(+), 51 deletions(-) diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index 8221ecc05a..929b4dc8a5 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -76,7 +76,6 @@ use crate::settings::{ AISettings, AISettingsChangedEvent, PrivacySettings, PrivacySettingsChangedEvent, }; use crate::settings_view::SettingsSection; -use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; #[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::plugin_manager::{ compare_versions, plugin_manager_for, plugin_manager_for_with_shell, CliAgentPluginManager, @@ -507,12 +506,12 @@ impl AgentInputFooter { me.plugin_chip_ready = false; } - // When a listener connects for an agent with rich status, - // the plugin is verified installed — hide the chip. - // (Codex always has a listener but no actual plugin to install.) + // When a structured plugin connects, the plugin is verified + // installed — hide the chip. Codex's OSC 9 fallback is not a + // structured plugin, so its chip stays until the plugin connects. if CLIAgentSessionsModel::as_ref(ctx) .session(me.terminal_view_id) - .is_some_and(|s| s.listener.is_some() && agent_supports_rich_status(&s.agent)) + .is_some_and(|s| s.supports_rich_status()) { me.plugin_chip_ready = false; } @@ -533,10 +532,7 @@ impl AgentInputFooter { |me, _, ctx: &mut ViewContext| { let suppress = CLIAgentSessionsModel::as_ref(ctx) .session(me.terminal_view_id) - .is_some_and(|s| { - s.listener.is_some() - && agent_supports_rich_status(&s.agent) - }); + .is_some_and(|s| s.supports_rich_status()); if !suppress { me.plugin_chip_ready = true; ctx.notify(); diff --git a/app/src/terminal/cli_agent_sessions/listener/mod.rs b/app/src/terminal/cli_agent_sessions/listener/mod.rs index 12fdbd0bc0..e93b05e715 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod.rs @@ -23,20 +23,6 @@ trait CLIAgentSessionHandler { /// Decide whether a parsed event should be forwarded to the sessions model. /// Returns the event (possibly transformed) if it should be processed. fn handle_event(&mut self, event: CLIAgentEvent) -> Option; - - /// Whether this handler provides meaningful, fine-grained status - /// (e.g. in-progress / blocked / success) that should be shown in the UI. - /// Handlers backed by the structured plugin protocol report rich status; - /// handlers that only forward opaque OS notifications (e.g. Codex) do not. - fn supports_rich_status(&self) -> bool { - true - } -} - -/// Whether the listener for the given agent provides rich status. -/// Returns `false` for agents without a handler or whose handler opts out. -pub fn agent_supports_rich_status(agent: &CLIAgent) -> bool { - create_handler(agent).is_some_and(|h| h.supports_rich_status()) } /// Returns `true` if the given CLI agent has a supported session handler. @@ -154,10 +140,6 @@ impl CLIAgentSessionHandler for CodexSessionHandler { fn handle_event(&mut self, event: CLIAgentEvent) -> Option { Some(event) } - - fn supports_rich_status(&self) -> bool { - false - } } /// Per-agent listener that subscribes to PTY events and forwards them to the diff --git a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs index 651ab43c73..aa11e0d6de 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs @@ -97,10 +97,6 @@ fn auggie_is_supported() { assert!(is_agent_supported(&CLIAgent::Auggie)); } -#[test] -fn auggie_uses_default_handler_with_rich_status() { - assert!(agent_supports_rich_status(&CLIAgent::Auggie)); -} #[test] fn auggie_default_handler_skips_session_start() { @@ -137,10 +133,6 @@ fn pi_is_supported() { assert!(is_agent_supported(&CLIAgent::Pi)); } -#[test] -fn pi_uses_default_handler_with_rich_status() { - assert!(agent_supports_rich_status(&CLIAgent::Pi)); -} #[test] fn pi_default_handler_skips_session_start() { diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index d4aced60e4..dff57e4aa0 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -146,6 +146,7 @@ impl CLIAgentSession { pub fn is_remote(&self) -> bool { self.remote_host.is_some() } + /// Whether this session is backed by a connected structured plugin (rich /// OSC 777 events) rather than command detection or Codex's OSC 9 fallback. /// Codex's structured plugin always reports a version on connect, so a @@ -156,10 +157,12 @@ impl CLIAgentSession { && (!matches!(self.agent, CLIAgent::Codex) || self.plugin_version.is_some()) } + /// Whether the session surfaces trustworthy fine-grained status + /// (in-progress / blocked / success). True exactly when a structured + /// plugin is connected; Codex's OSC 9 fallback only emits opaque `Stop` + /// notifications and so does not qualify. pub fn supports_rich_status(&self) -> bool { self.has_structured_plugin() - && (matches!(self.agent, CLIAgent::Codex) - || listener::agent_supports_rich_status(&self.agent)) } /// Clears state populated by `PermissionRequest`. Called whenever the diff --git a/app/src/terminal/view/use_agent_footer/mod.rs b/app/src/terminal/view/use_agent_footer/mod.rs index d302c851a0..4a8e032439 100644 --- a/app/src/terminal/view/use_agent_footer/mod.rs +++ b/app/src/terminal/view/use_agent_footer/mod.rs @@ -11,7 +11,6 @@ use crate::ai::agent::ImageContext; use crate::ai::blocklist::agent_view::agent_input_footer::{ AgentInputFooter, AgentInputFooterEvent, }; -use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::{CLIAgentInputEntrypoint, CLIAgentSessionsModel}; use crate::terminal::shared_session::{ SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, @@ -613,11 +612,9 @@ impl TerminalView { /// instead). Otherwise, respects the auto-dismiss-after-submit setting. fn maybe_close_rich_input_after_submit(&mut self, ctx: &mut ViewContext) { let session = CLIAgentSessionsModel::as_ref(ctx).session(self.view_id); - let has_plugin = session.as_ref().is_some_and(|s| { - s.listener.is_some() - && s.should_auto_toggle_input - && agent_supports_rich_status(&s.agent) - }); + let has_plugin = session + .as_ref() + .is_some_and(|s| s.supports_rich_status() && s.should_auto_toggle_input); let ai_settings = AISettings::as_ref(ctx); let should_close = if has_plugin && *ai_settings.auto_toggle_rich_input { diff --git a/app/src/ui_components/agent_icon.rs b/app/src/ui_components/agent_icon.rs index b90229695a..b817bf5de9 100644 --- a/app/src/ui_components/agent_icon.rs +++ b/app/src/ui_components/agent_icon.rs @@ -16,7 +16,6 @@ use crate::ai::agent_conversations_model::{ AgentConversationEntry, AgentConversationProvenance, AgentConversationsModel, AgentRunDisplayStatus, }; -use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::view::TerminalView; use crate::terminal::CLIAgent; @@ -72,7 +71,7 @@ pub(crate) fn terminal_view_agent_icon_variant( agent: session.agent, has_listener: session.listener.is_some(), status: session.status.to_conversation_status(), - supports_rich_status: agent_supports_rich_status(&session.agent), + supports_rich_status: session.supports_rich_status(), }), selected_third_party_cli_agent: terminal_view .ambient_agent_view_model() diff --git a/app/src/workspace/view/vertical_tabs.rs b/app/src/workspace/view/vertical_tabs.rs index eb9284c3f4..f8d9f6059b 100644 --- a/app/src/workspace/view/vertical_tabs.rs +++ b/app/src/workspace/view/vertical_tabs.rs @@ -55,7 +55,6 @@ use crate::pane_group::{ }; use crate::safe_triangle::SafeTriangle; use crate::tab::{tab_position_id, SelectedTabColor, TabData}; -use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::session_settings::SessionSettings; use crate::terminal::view::TerminalViewState; @@ -925,9 +924,8 @@ fn summary_conversation_status_for_terminal( ) -> Option { let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id()); if let Some(session) = cli_agent_session - .filter(|s| s.listener.is_some()) + .filter(|s| s.supports_rich_status()) .filter(|s| !matches!(s.agent, CLIAgent::Unknown)) - .filter(|s| agent_supports_rich_status(&s.agent)) { return Some(session.status.to_conversation_status()); } @@ -6172,9 +6170,7 @@ fn render_terminal_detail_section( let (conversation_display_title, cli_agent_title) = preferred_agent_tab_titles(&agent_text, agent_tab_text_preference(app)); let kind_label = terminal_kind_badge_label(agent_text.is_oz_agent, agent_text.cli_agent); - let status = if let Some(session) = - cli_agent_session.filter(|s| s.listener.is_some() && agent_supports_rich_status(&s.agent)) - { + let status = if let Some(session) = cli_agent_session.filter(|s| s.supports_rich_status()) { Some(session.status.to_conversation_status()) } else if agent_text.is_oz_agent { terminal_view.selected_conversation_status_for_display(app) From 91884f197887857987f261e9b6a5e4f6b8ae9d7f Mon Sep 17 00:00:00 2001 From: liliwilson Date: Tue, 2 Jun 2026 21:09:19 -0700 Subject: [PATCH 05/11] Tech spec --- specs/codex-warp-plugin/TECH.md | 118 ++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 specs/codex-warp-plugin/TECH.md diff --git a/specs/codex-warp-plugin/TECH.md b/specs/codex-warp-plugin/TECH.md new file mode 100644 index 0000000000..6ed04c931c --- /dev/null +++ b/specs/codex-warp-plugin/TECH.md @@ -0,0 +1,118 @@ +# Codex Warp Plugin Tech Spec + +## Context +Warp already supports CLI agent status via structured OSC 777 plugin notifications. Codex is special because it previously only had native OSC 9 desktop notifications with plain text. This branch adds a feature-flagged Codex Warp plugin path while preserving OSC 9 as fallback. +The key invariant changes: +- `listener.is_some()` no longer means “structured plugin connected” for Codex. It can mean OSC 9 fallback listener. +- Structured Codex plugin events must win over OSC 9 once seen. Otherwise Warp would process both OSC 777 and OSC 9 and notify twice. +- UI that needs trustworthy state should use `CLIAgentSession::supports_rich_status()`, not `listener.is_some()`. +Relevant code: +- [`app/src/terminal/cli_agent_sessions/listener/mod.rs:14`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/listener/mod.rs#L14) — `CLIAgentSessionHandler` now mutably parses notifications so Codex can remember structured-plugin activation. +- [`app/src/terminal/cli_agent_sessions/listener/mod.rs:87`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/listener/mod.rs#L87) — `CodexSessionHandler` parses both OSC 9 fallback and structured OSC 777 Codex events. +- `app/src/terminal/cli_agent_sessions/mod.rs:155` — `has_structured_plugin()` distinguishes structured OSC 777 from Codex OSC 9 fallback. +- `app/src/terminal/cli_agent_sessions/mod.rs:164` — `supports_rich_status()` centralizes the “safe to show fine-grained status” check. +- [`app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs:17`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs#L17) — Codex plugin constants: marketplace, user plugin key, platform plugin key, config dirs, min version. +- [`app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs:57`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs#L57) — plugin manager gates install/update/inspection on `FeatureFlag::CodexPlugin`. +- [`app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs:17`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs#L17) — Claude manager structure copied for Codex, with CLI/config differences. +- [`app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs:222`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs#L222) — Codex manager now receives shell path/type/PATH like Claude/Gemini. +- `app/src/terminal/view.rs:11680` — command-detected Codex still proactively registers a listener, but without seeding a plugin version. +- `app/src/terminal/view.rs:12753` — OSC 777 Codex events are ignored when `CodexPlugin` is disabled. +- `app/src/terminal/view.rs:12825` — mid-session plugin install/update can seed minimum plugin version, while OSC 9 fallback cannot. +- [`crates/warp_features/src/lib.rs:789`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/crates/warp_features/src/lib.rs#L789) and [`app/src/features.rs:480`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/features.rs#L480) — new `CodexPlugin` flag is wired into shared/app feature plumbing. + +## Proposed changes +### 1. Add `CodexPlugin` flag +Add `codex_plugin = []` to `app/Cargo.toml`, add `FeatureFlag::CodexPlugin` to the app feature bridge, add it to `warp_features`, and enable it for dogfood builds. +When disabled: +- Codex keeps the existing native OSC 9 behavior. +- structured Codex events are ignored. +- install instructions remain the old “enable native Codex notifications” steps. +- auto-install/update are disabled. +When enabled: +- Warp can install/update `warp@codex-warp`. +- structured OSC 777 events unlock rich status. +- OSC 9 remains fallback for older Codex clients. + +### 2. Make Codex listener protocol-aware +`CLIAgentSessionHandler::try_parse` now takes `&mut self`. Codex uses that state to remember when it has seen a structured Codex plugin event. +Codex parsing rules: +- Try `parse_event(title, body)` first. +- If it is a Codex structured event and `CodexPlugin` is enabled, mark `structured_plugin_active = true` and forward it. +- If it is a Codex structured event but the flag is disabled, drop it and leave OSC 9 fallback active. +- If it is a structured event for another agent, drop it. +- If it is OSC 9 (`title == None`) and no structured plugin has been seen, convert text to `Stop`. +- If structured plugin is active, ignore later OSC 9 so Warp does not emit duplicate status/notifications. +OSC 9 remains intentionally lossy. It cannot encode permission/blocking/in-progress state, so it maps non-empty text to `Stop` with the body as `query`. + +### 3. Move rich-status checks onto session state +The old `agent_supports_rich_status(agent)` helper was static and could not distinguish Codex plugin from Codex OSC 9 fallback. +`CLIAgentSession` now owns the distinction: +- `has_structured_plugin()` is true when a listener exists and, for Codex, a plugin version exists. +- `supports_rich_status()` delegates to `has_structured_plugin()`. +This works because the Codex structured plugin reports a version on connect, while OSC 9 fallback does not. +`apply_event()` now records `payload.plugin_version` from any event, and `update_from_event()` emits `SessionUpdated` when the version changes. This lets footer/UI surfaces re-evaluate chip/status state after the structured plugin connects. + +### 4. Keep Codex command detection fallback +Codex command detection still registers a listener immediately, because OSC 9 has no `SessionStart` sentinel. +Important distinction: +- Command-detected Codex calls `register_cli_agent_listener_without_session_start_event(CLIAgent::Codex, false, ctx)`, so no plugin version is seeded. This is fallback mode. +- The `RegisterPluginListener` action after install/update calls the same helper with `seed_plugin_version = true`, so the new plugin is treated as current and the update chip does not flash before restart/reload. + +### 5. Update UI consumers to use rich-status semantics +All surfaces that previously treated `listener.is_some()` plus static agent support as rich-status now use `session.supports_rich_status()`: +- footer plugin-chip suppression and update-chip logic +- auto show/hide of rich input +- close-on-submit behavior +- terminal icon status +- vertical tabs summary/detail status +This prevents OSC 9 fallback Codex sessions from showing precise statuses or suppressing plugin install chips too early. + +### 6. Add Codex plugin manager +The Codex manager follows the Claude manager shape: +- shell-aware `LocalCommandExecutor` +- PATH override support for tools installed by shell managers +- marketplace add/remove/install/update flow +- local install detection +- cached manifest version detection +- platform plugin install hook + +Key Codex differences: +- CLI binary is `codex`, not `claude`. +- User plugin command is `codex plugin add warp@codex-warp`, not `claude plugin install warp@claude-code-warp`. +- Update removes marketplace `codex-warp`, re-adds `warpdotdev/codex-warp`, then runs `codex plugin add warp@codex-warp`. +- User plugin key is `warp@codex-warp`. +- Platform plugin key is `orchestration@codex-warp`. +- Config root is `$CODEX_HOME` or `~/.codex`. +- Install state reads `[plugins."warp@codex-warp"].enabled = true` from `config.toml`. +- Version state reads cached manifests under `plugins/cache/codex-warp/warp/*/.codex-plugin/plugin.json`. +- Success copy says restart Codex, not `/reload-plugins`. + +## Testing and validation +Unit coverage added/updated: +- `CodexSessionHandler` parses OSC 9 text as `Stop`. +- empty OSC 9 bodies are ignored. +- titled non-structured notifications are ignored. +- structured Codex events are ignored when `CodexPlugin` is disabled. +- after one structured Codex event, later OSC 9 is ignored. +- structured events for other agents are ignored by the Codex handler. +- Codex plugin manager toggles install/update support by `CodexPlugin`. +- plugin install/update instruction commands match Codex command names. +- native fallback instructions remain when `CodexPlugin` is off. +- install detection reads `config.toml`. +- version detection picks latest cached plugin manifest version. +- `CODEX_HOME` overrides the default `~/.codex` path in install/update checks. +Suggested local validation: +- `cargo test -p warp --features test-util codex` +- `cargo test -p warp --features test-util cli_agent_sessions` +- Run Warp with `CodexPlugin` disabled, start Codex with native notifications, verify one completion notification and no rich status. +- Run Warp with `CodexPlugin` enabled and plugin installed, verify permission requests show rich blocked state. +- In plugin mode, verify Codex emitting both OSC 777 and OSC 9 does not produce duplicate notifications. +- Verify footer install/update chip remains visible for OSC 9 fallback, then hides after structured plugin connects. +- Verify vertical tabs and agent icon do not show rich status for OSC 9 fallback. +## Parallelization +No sub-agents recommended for implementation. The change is tightly coupled around one invariant: whether a Codex session is OSC 9 fallback or structured OSC 777 plugin-backed. +Best review split: +- Protocol/session review: `listener/mod.rs`, `cli_agent_sessions/mod.rs`, `terminal/view.rs`. +- Plugin manager review: `plugin_manager/codex.rs`, `codex_tests.rs`, `plugin_manager/mod.rs`. +- UI surface review: footer, rich input, agent icon, vertical tabs. +These can be reviewed in parallel, but changes should land as one PR because the protocol invariant must stay consistent across all consumers. From 43c8c2a55123e1ba15b9677eee9fba3a0d6aa2ea Mon Sep 17 00:00:00 2001 From: liliwilson Date: Tue, 2 Jun 2026 21:36:22 -0700 Subject: [PATCH 06/11] Self-review --- app/src/terminal/cli_agent_sessions/mod.rs | 9 ++------- .../cli_agent_sessions/plugin_manager/codex.rs | 6 +----- app/src/terminal/view.rs | 15 +++------------ skills-lock.json | 16 ++-------------- specs/codex-warp-plugin/TECH.md | 14 ++++++++------ 5 files changed, 16 insertions(+), 44 deletions(-) diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index dff57e4aa0..d07e1c42bd 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -179,9 +179,6 @@ impl CLIAgentSession { /// Applies an event to this session, updating context and status. /// Returns the new status if it changed, or `None` if the event was irrelevant. fn apply_event(&mut self, event: &CLIAgentEvent) -> Option { - if let Some(plugin_version) = &event.payload.plugin_version { - self.plugin_version = Some(plugin_version.clone()); - } self.session_context.cwd = event.cwd.clone().or(self.session_context.cwd.take()); self.session_context.project = event .project @@ -237,6 +234,7 @@ impl CLIAgentSession { // This should not affect status — otherwise it would override Success after a Stop event. CLIAgentEventType::IdlePrompt => return None, CLIAgentEventType::SessionStart => { + self.plugin_version = event.payload.plugin_version.clone(); return None; } CLIAgentEventType::Unknown(_) => return None, @@ -423,7 +421,6 @@ impl CLIAgentSessionsModel { }; let event_type = &event.event; - let old_plugin_version = session.plugin_version.clone(); if let Some(new_status) = session.apply_event(event) { let agent = session.agent; ctx.emit(CLIAgentSessionsModelEvent::StatusChanged { @@ -439,8 +436,7 @@ impl CLIAgentSessionsModel { CLIAgentEventType::SessionStart | CLIAgentEventType::PromptSubmit | CLIAgentEventType::ToolComplete - ) || session.plugin_version != old_plugin_version - { + ) { ctx.emit(CLIAgentSessionsModelEvent::SessionUpdated { terminal_view_id, agent: session.agent, @@ -508,7 +504,6 @@ impl CLIAgentSessionsModel { ctx: &mut ModelContext, ) { let agent = session.agent; - // Close any open rich input before replacing, so subscribers can // restore input config before the session ends. self.close_input(terminal_view_id, false, ctx); diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs index d340e2cb83..fd8d117d01 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs @@ -142,11 +142,7 @@ impl CliAgentPluginManager for CodexPluginManager { } fn install_success_message(&self) -> &'static str { - if FeatureFlag::CodexPlugin.is_enabled() { - "Warp plugin installed. Please restart Codex to activate." - } else { - "Warp notifications enabled. Please restart Codex to activate." - } + "Warp plugin installed. Please restart Codex to activate." } fn update_success_message(&self) -> &'static str { diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 1b152f81c8..80c61bd5d5 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -404,7 +404,7 @@ use crate::terminal::cli_agent_sessions::event::{ }; use crate::terminal::cli_agent_sessions::listener::{is_agent_supported, CLIAgentSessionListener}; #[cfg(not(target_family = "wasm"))] -use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; +use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind; use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, @@ -11819,7 +11819,6 @@ impl TerminalView { if matches!(detection, Some((CLIAgent::Codex, _))) { me.register_cli_agent_listener_without_session_start_event( CLIAgent::Codex, - false, ctx, ); } @@ -12965,16 +12964,8 @@ impl TerminalView { fn register_cli_agent_listener_without_session_start_event( &mut self, agent: CLIAgent, - seed_plugin_version: bool, ctx: &mut ViewContext, ) { - #[cfg(not(target_family = "wasm"))] - let plugin_version = seed_plugin_version - .then(|| plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned())) - .flatten(); - #[cfg(target_family = "wasm")] - let plugin_version = None; - let notification = CLIAgentEvent { v: 1, agent, @@ -12983,7 +12974,7 @@ impl TerminalView { cwd: None, project: None, payload: CLIAgentEventPayload { - plugin_version, + plugin_version: None, ..Default::default() }, }; @@ -21357,7 +21348,7 @@ impl TerminalView { self.enter_environment_setup_selector(repos.clone(), ctx); } InputEvent::RegisterPluginListener(agent) => { - self.register_cli_agent_listener_without_session_start_event(*agent, true, ctx); + self.register_cli_agent_listener_without_session_start_event(*agent, ctx); } #[cfg(not(target_family = "wasm"))] InputEvent::OpenPluginInstructionsPane(agent, kind) => { diff --git a/skills-lock.json b/skills-lock.json index 800b703b99..02dd8bde28 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -61,12 +61,6 @@ "skillPath": ".agents/skills/resolve-merge-conflicts/SKILL.md", "computedHash": "5376b5692901c624e8f20a5a04aeea5f5a94f5168d29852a8a639aded6408f2e" }, - "respond-to-pr-comments-in-blocklist": { - "source": "warpdotdev/common-skills", - "sourceType": "github", - "skillPath": ".agents/skills/respond-to-pr-comments-in-blocklist/SKILL.md", - "computedHash": "f7408cf90c10397aa9048f14ab985a138641fc1e5f3245e290150437d62875f0" - }, "review-pr": { "source": "warpdotdev/common-skills", "sourceType": "github", @@ -77,7 +71,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/spec-driven-implementation/SKILL.md", - "computedHash": "45793ca1e35b032ddfd2596f2e86fd6f6e938549373bfe4aeb74683486a179e4" + "computedHash": "e334d0f6f0e8fc39055314acad911f36d92d1919372b5e2973cc99d7f8c901b4" }, "update-skill": { "source": "warpdotdev/common-skills", @@ -85,12 +79,6 @@ "skillPath": ".agents/skills/update-skill/SKILL.md", "computedHash": "1e23c5a5c37ed084eced7fa507031e3cdb8e23f09cd5d004e00efd6f66bf200f" }, - "validate-changes-match-specs": { - "source": "warpdotdev/common-skills", - "sourceType": "github", - "skillPath": ".agents/skills/validate-changes-match-specs/SKILL.md", - "computedHash": "9123fd70ced064bdd773fd8d2baa8d5d5291fef910eb2c028084074b3ac72c27" - }, "write-product-spec": { "source": "warpdotdev/common-skills", "sourceType": "github", @@ -101,7 +89,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/write-tech-spec/SKILL.md", - "computedHash": "c7913bfd1ea2be7ce38d5beb7e923b96f5689f6145250af1d81b985e8be4a882" + "computedHash": "3b5eb4ef021112d473984eca28412d372e87d9337ad5d9754f3ad3e01f94d39b" } } } diff --git a/specs/codex-warp-plugin/TECH.md b/specs/codex-warp-plugin/TECH.md index 6ed04c931c..9794da2291 100644 --- a/specs/codex-warp-plugin/TECH.md +++ b/specs/codex-warp-plugin/TECH.md @@ -17,7 +17,7 @@ Relevant code: - [`app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs:222`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/terminal/cli_agent_sessions/plugin_manager/mod.rs#L222) — Codex manager now receives shell path/type/PATH like Claude/Gemini. - `app/src/terminal/view.rs:11680` — command-detected Codex still proactively registers a listener, but without seeding a plugin version. - `app/src/terminal/view.rs:12753` — OSC 777 Codex events are ignored when `CodexPlugin` is disabled. -- `app/src/terminal/view.rs:12825` — mid-session plugin install/update can seed minimum plugin version, while OSC 9 fallback cannot. +- `app/src/terminal/view.rs:12825` — listener registration without `SessionStart` never seeds a plugin version. Codex remains OSC 9 fallback until a real structured plugin event reports version. - [`crates/warp_features/src/lib.rs:789`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/crates/warp_features/src/lib.rs#L789) and [`app/src/features.rs:480`](https://github.com/warpdotdev/warp/blob/68acdf7601cc1824d4f4dd250485aca8329efc17/app/src/features.rs#L480) — new `CodexPlugin` flag is wired into shared/app feature plumbing. ## Proposed changes @@ -49,14 +49,16 @@ The old `agent_supports_rich_status(agent)` helper was static and could not dist `CLIAgentSession` now owns the distinction: - `has_structured_plugin()` is true when a listener exists and, for Codex, a plugin version exists. - `supports_rich_status()` delegates to `has_structured_plugin()`. -This works because the Codex structured plugin reports a version on connect, while OSC 9 fallback does not. +This works because the Codex structured plugin reports a version on `session_start`, while OSC 9 fallback does not. `apply_event()` now records `payload.plugin_version` from any event, and `update_from_event()` emits `SessionUpdated` when the version changes. This lets footer/UI surfaces re-evaluate chip/status state after the structured plugin connects. ### 4. Keep Codex command detection fallback Codex command detection still registers a listener immediately, because OSC 9 has no `SessionStart` sentinel. Important distinction: -- Command-detected Codex calls `register_cli_agent_listener_without_session_start_event(CLIAgent::Codex, false, ctx)`, so no plugin version is seeded. This is fallback mode. -- The `RegisterPluginListener` action after install/update calls the same helper with `seed_plugin_version = true`, so the new plugin is treated as current and the update chip does not flash before restart/reload. +- Command-detected Codex calls `register_cli_agent_listener_without_session_start_event(CLIAgent::Codex, ctx)`. +- `RegisterPluginListener` after install/update uses the same helper and also does not seed a plugin version. +- Installing the plugin mid-session does not make the running Codex process emit OSC 777. Codex must be restarted to load hooks and start structured notifications. +- Until restart, Warp should keep treating the session as OSC 9 fallback. Disk install state can drive install/update/restart UI, but not `has_structured_plugin()`. ### 5. Update UI consumers to use rich-status semantics All surfaces that previously treated `listener.is_some()` plus static agent support as rich-status now use `session.supports_rich_status()`: @@ -105,9 +107,9 @@ Suggested local validation: - `cargo test -p warp --features test-util codex` - `cargo test -p warp --features test-util cli_agent_sessions` - Run Warp with `CodexPlugin` disabled, start Codex with native notifications, verify one completion notification and no rich status. -- Run Warp with `CodexPlugin` enabled and plugin installed, verify permission requests show rich blocked state. +- Run Warp with `CodexPlugin` enabled and plugin active after Codex restart, verify permission requests show rich blocked state. - In plugin mode, verify Codex emitting both OSC 777 and OSC 9 does not produce duplicate notifications. -- Verify footer install/update chip remains visible for OSC 9 fallback, then hides after structured plugin connects. +- Verify footer install/update/restart UI remains visible for OSC 9 fallback, then hides after structured plugin connects after restart. - Verify vertical tabs and agent icon do not show rich status for OSC 9 fallback. ## Parallelization No sub-agents recommended for implementation. The change is tightly coupled around one invariant: whether a Codex session is OSC 9 fallback or structured OSC 777 plugin-backed. From c7be3de36b5acdae7e80daf0ca69c6b661880a9f Mon Sep 17 00:00:00 2001 From: liliwilson Date: Tue, 2 Jun 2026 22:23:34 -0700 Subject: [PATCH 07/11] Warpy --- app/src/terminal/view.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 80c61bd5d5..99d7aa3961 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -11776,7 +11776,6 @@ impl TerminalView { let model = me.model.lock(); me.detect_cli_agent_from_model(&model, ctx) }; - let view_id = me.view_id; CLIAgentSessionsModel::handle(ctx).update( ctx, @@ -12940,7 +12939,6 @@ impl TerminalView { let remote_host = self.active_session_remote_host(ctx); let should_auto_toggle_input = *AISettings::as_ref(ctx).auto_open_rich_input_on_cli_agent_start; - // Seed context from the event that caused registration before the // listener subscribes to future events. CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { @@ -12966,6 +12964,14 @@ impl TerminalView { agent: CLIAgent, ctx: &mut ViewContext, ) { + // No SessionStart event in this path (mid-session install/update). + // Assume the just-installed plugin meets the minimum version for this agent + // so the update chip doesn't flash before the user runs /reload-plugins. + #[cfg(not(target_family = "wasm"))] + let plugin_version = + plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned()); + #[cfg(target_family = "wasm")] + let plugin_version = None; let notification = CLIAgentEvent { v: 1, agent, @@ -12974,7 +12980,7 @@ impl TerminalView { cwd: None, project: None, payload: CLIAgentEventPayload { - plugin_version: None, + plugin_version, ..Default::default() }, }; From a16c1183b3607d1613ee63173592bfd8361fdeef Mon Sep 17 00:00:00 2001 From: liliwilson Date: Tue, 2 Jun 2026 22:39:23 -0700 Subject: [PATCH 08/11] Making TECH.md slightly more readable --- specs/codex-warp-plugin/TECH.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/specs/codex-warp-plugin/TECH.md b/specs/codex-warp-plugin/TECH.md index 9794da2291..0eaf006aee 100644 --- a/specs/codex-warp-plugin/TECH.md +++ b/specs/codex-warp-plugin/TECH.md @@ -22,7 +22,7 @@ Relevant code: ## Proposed changes ### 1. Add `CodexPlugin` flag -Add `codex_plugin = []` to `app/Cargo.toml`, add `FeatureFlag::CodexPlugin` to the app feature bridge, add it to `warp_features`, and enable it for dogfood builds. +Add `FeatureFlag::CodexPlugin` and enable it for dogfood builds. When disabled: - Codex keeps the existing native OSC 9 behavior. - structured Codex events are ignored. @@ -33,6 +33,8 @@ When enabled: - structured OSC 777 events unlock rich status. - OSC 9 remains fallback for older Codex clients. +This means we can test this before releasing into the wild. + ### 2. Make Codex listener protocol-aware `CLIAgentSessionHandler::try_parse` now takes `&mut self`. Codex uses that state to remember when it has seen a structured Codex plugin event. Codex parsing rules: @@ -42,7 +44,6 @@ Codex parsing rules: - If it is a structured event for another agent, drop it. - If it is OSC 9 (`title == None`) and no structured plugin has been seen, convert text to `Stop`. - If structured plugin is active, ignore later OSC 9 so Warp does not emit duplicate status/notifications. -OSC 9 remains intentionally lossy. It cannot encode permission/blocking/in-progress state, so it maps non-empty text to `Stop` with the body as `query`. ### 3. Move rich-status checks onto session state The old `agent_supports_rich_status(agent)` helper was static and could not distinguish Codex plugin from Codex OSC 9 fallback. @@ -83,7 +84,6 @@ Key Codex differences: - User plugin command is `codex plugin add warp@codex-warp`, not `claude plugin install warp@claude-code-warp`. - Update removes marketplace `codex-warp`, re-adds `warpdotdev/codex-warp`, then runs `codex plugin add warp@codex-warp`. - User plugin key is `warp@codex-warp`. -- Platform plugin key is `orchestration@codex-warp`. - Config root is `$CODEX_HOME` or `~/.codex`. - Install state reads `[plugins."warp@codex-warp"].enabled = true` from `config.toml`. - Version state reads cached manifests under `plugins/cache/codex-warp/warp/*/.codex-plugin/plugin.json`. @@ -103,6 +103,7 @@ Unit coverage added/updated: - install detection reads `config.toml`. - version detection picks latest cached plugin manifest version. - `CODEX_HOME` overrides the default `~/.codex` path in install/update checks. + Suggested local validation: - `cargo test -p warp --features test-util codex` - `cargo test -p warp --features test-util cli_agent_sessions` @@ -111,6 +112,7 @@ Suggested local validation: - In plugin mode, verify Codex emitting both OSC 777 and OSC 9 does not produce duplicate notifications. - Verify footer install/update/restart UI remains visible for OSC 9 fallback, then hides after structured plugin connects after restart. - Verify vertical tabs and agent icon do not show rich status for OSC 9 fallback. + ## Parallelization No sub-agents recommended for implementation. The change is tightly coupled around one invariant: whether a Codex session is OSC 9 fallback or structured OSC 777 plugin-backed. Best review split: From 2b742e0289f3a264f2b4c6405421ee2c46ae32d5 Mon Sep 17 00:00:00 2001 From: liliwilson Date: Tue, 2 Jun 2026 23:15:11 -0700 Subject: [PATCH 09/11] Fix thing I broke while cleaning up --- .../cli_agent_sessions/listener/mod_tests.rs | 2 -- app/src/terminal/view.rs | 17 +++++++++++------ skills-lock.json | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs index aa11e0d6de..5d0582b4c7 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs @@ -97,7 +97,6 @@ fn auggie_is_supported() { assert!(is_agent_supported(&CLIAgent::Auggie)); } - #[test] fn auggie_default_handler_skips_session_start() { let mut handler = DefaultSessionListener; @@ -133,7 +132,6 @@ fn pi_is_supported() { assert!(is_agent_supported(&CLIAgent::Pi)); } - #[test] fn pi_default_handler_skips_session_start() { let mut handler = DefaultSessionListener; diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 99d7aa3961..3fa667ce8d 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -404,7 +404,7 @@ use crate::terminal::cli_agent_sessions::event::{ }; use crate::terminal::cli_agent_sessions::listener::{is_agent_supported, CLIAgentSessionListener}; #[cfg(not(target_family = "wasm"))] -use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind; +use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, @@ -12964,12 +12964,17 @@ impl TerminalView { agent: CLIAgent, ctx: &mut ViewContext, ) { - // No SessionStart event in this path (mid-session install/update). - // Assume the just-installed plugin meets the minimum version for this agent - // so the update chip doesn't flash before the user runs /reload-plugins. #[cfg(not(target_family = "wasm"))] - let plugin_version = - plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned()); + let plugin_version = if matches!(agent, CLIAgent::Codex) { + // We use the lack of a plugin version for codex to differentiate between + // OSC 9 notification fallback and real plugin. + None + } else { + // No SessionStart event in this path (mid-session install/update). + // Assume the just-installed plugin meets the minimum version for this agent + // so the update chip doesn't flash before the user runs /reload-plugins. + plugin_manager_for(agent).map(|m| m.minimum_plugin_version().to_owned()) + }; #[cfg(target_family = "wasm")] let plugin_version = None; let notification = CLIAgentEvent { diff --git a/skills-lock.json b/skills-lock.json index 02dd8bde28..800b703b99 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -61,6 +61,12 @@ "skillPath": ".agents/skills/resolve-merge-conflicts/SKILL.md", "computedHash": "5376b5692901c624e8f20a5a04aeea5f5a94f5168d29852a8a639aded6408f2e" }, + "respond-to-pr-comments-in-blocklist": { + "source": "warpdotdev/common-skills", + "sourceType": "github", + "skillPath": ".agents/skills/respond-to-pr-comments-in-blocklist/SKILL.md", + "computedHash": "f7408cf90c10397aa9048f14ab985a138641fc1e5f3245e290150437d62875f0" + }, "review-pr": { "source": "warpdotdev/common-skills", "sourceType": "github", @@ -71,7 +77,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/spec-driven-implementation/SKILL.md", - "computedHash": "e334d0f6f0e8fc39055314acad911f36d92d1919372b5e2973cc99d7f8c901b4" + "computedHash": "45793ca1e35b032ddfd2596f2e86fd6f6e938549373bfe4aeb74683486a179e4" }, "update-skill": { "source": "warpdotdev/common-skills", @@ -79,6 +85,12 @@ "skillPath": ".agents/skills/update-skill/SKILL.md", "computedHash": "1e23c5a5c37ed084eced7fa507031e3cdb8e23f09cd5d004e00efd6f66bf200f" }, + "validate-changes-match-specs": { + "source": "warpdotdev/common-skills", + "sourceType": "github", + "skillPath": ".agents/skills/validate-changes-match-specs/SKILL.md", + "computedHash": "9123fd70ced064bdd773fd8d2baa8d5d5291fef910eb2c028084074b3ac72c27" + }, "write-product-spec": { "source": "warpdotdev/common-skills", "sourceType": "github", @@ -89,7 +101,7 @@ "source": "warpdotdev/common-skills", "sourceType": "github", "skillPath": ".agents/skills/write-tech-spec/SKILL.md", - "computedHash": "3b5eb4ef021112d473984eca28412d372e87d9337ad5d9754f3ad3e01f94d39b" + "computedHash": "c7913bfd1ea2be7ce38d5beb7e923b96f5689f6145250af1d81b985e8be4a882" } } } From f643fb2d9a0bb42ed25466aef327ede75774e1d8 Mon Sep 17 00:00:00 2001 From: liliwilson Date: Wed, 3 Jun 2026 09:56:11 -0700 Subject: [PATCH 10/11] Simplify upgrade flow --- .../plugin_manager/codex.rs | 20 +++---------------- .../plugin_manager/codex_tests.rs | 6 +----- app/src/terminal/view.rs | 2 +- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs index fd8d117d01..fe19556183 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex.rs @@ -110,16 +110,8 @@ impl CliAgentPluginManager for CodexPluginManager { return Ok(()); } let mut log = String::new(); - // Remove and re-add the marketplace to refresh the local snapshot, then - // reinstall the plugin. Mirrors the Claude update flow. - let _ = self - .run_logged( - &["plugin", "marketplace", "remove", MARKETPLACE_NAME], - &mut log, - ) - .await; self.run_logged( - &["plugin", "marketplace", "add", MARKETPLACE_REPO], + &["plugin", "marketplace", "upgrade", MARKETPLACE_NAME], &mut log, ) .await?; @@ -241,14 +233,8 @@ static PLUGIN_UPDATE_INSTRUCTIONS: LazyLock = subtitle: "Run the following commands, then restart Codex.", steps: &[ PluginInstructionStep { - description: "Remove the existing marketplace (if present)", - command: "codex plugin marketplace remove codex-warp", - executable: true, - link: None, - }, - PluginInstructionStep { - description: "Re-add the marketplace", - command: "codex plugin marketplace add warpdotdev/codex-warp", + description: "Upgrade the marketplace", + command: "codex plugin marketplace upgrade codex-warp", executable: true, link: None, }, diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs index 21f6099040..c5de323e83 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs @@ -78,14 +78,10 @@ fn update_instructions_has_steps() { let instructions = CodexPluginManager::new(None, None, None).update_instructions(); assert_eq!( instructions.steps[0].command, - "codex plugin marketplace remove codex-warp" + "codex plugin marketplace upgrade codex-warp" ); assert_eq!( instructions.steps[1].command, - "codex plugin marketplace add warpdotdev/codex-warp" - ); - assert_eq!( - instructions.steps[2].command, "codex plugin add warp@codex-warp" ); assert!(!instructions.steps.is_empty()); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 3fa667ce8d..1f3a9ab01b 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -12967,7 +12967,7 @@ impl TerminalView { #[cfg(not(target_family = "wasm"))] let plugin_version = if matches!(agent, CLIAgent::Codex) { // We use the lack of a plugin version for codex to differentiate between - // OSC 9 notification fallback and real plugin. + // OSC 9 notification fallback and real plugin. None } else { // No SessionStart event in this path (mid-session install/update). From 28542e24ba07e2600bf00b5ad6ab9116c03ea7b4 Mon Sep 17 00:00:00 2001 From: liliwilson Date: Wed, 3 Jun 2026 12:49:30 -0700 Subject: [PATCH 11/11] Set rich notifications in a cleaner way. --- .../agent_view/agent_input_footer/mod.rs | 2 +- .../terminal/cli_agent_sessions/event/mod.rs | 10 +++ .../terminal/cli_agent_sessions/event/v1.rs | 3 +- .../cli_agent_sessions/listener/mod.rs | 48 +++++++++----- .../cli_agent_sessions/listener/mod_tests.rs | 43 ++++++++----- app/src/terminal/cli_agent_sessions/mod.rs | 33 +++++----- .../terminal/cli_agent_sessions/mod_tests.rs | 64 ++++++++++++++++++- .../plugin_manager/codex_tests.rs | 3 +- .../shared_session/shared_handlers.rs | 1 + app/src/terminal/view.rs | 4 +- .../view/use_agent_footer/mod_tests.rs | 1 + app/src/terminal/view_tests.rs | 27 +++++++- 12 files changed, 188 insertions(+), 51 deletions(-) diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index 929b4dc8a5..fc2402cc2e 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -1082,7 +1082,7 @@ impl AgentInputFooter { let chip_key = plugin_chip_key(session.agent.command_prefix(), &session.remote_host); // If a structured plugin is connected and this agent supports // version-based updates, check the reported version. - if session.has_structured_plugin() && manager.supports_update() { + if session.supports_rich_status() && manager.supports_update() { let needs_update = match &session.plugin_version { // No version reported = pre-versioning plugin, definitely outdated. None => true, diff --git a/app/src/terminal/cli_agent_sessions/event/mod.rs b/app/src/terminal/cli_agent_sessions/event/mod.rs index 1c7a7bdd33..768f30101e 100644 --- a/app/src/terminal/cli_agent_sessions/event/mod.rs +++ b/app/src/terminal/cli_agent_sessions/event/mod.rs @@ -25,6 +25,15 @@ pub enum CLIAgentEventType { Unknown(String), } +/// How a CLI agent event reached Warp. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CLIAgentEventSource { + /// Structured OSC 777 notification from a rich plugin. + RichPlugin, + /// Native Codex OSC 9 fallback notification. + CodexOsc9Fallback, +} + /// Event-specific fields that vary by event type. #[allow(dead_code)] #[derive(Debug, Clone, Default)] @@ -49,6 +58,7 @@ pub struct CLIAgentEvent { pub cwd: Option, pub project: Option, pub payload: CLIAgentEventPayload, + pub source: CLIAgentEventSource, } /// Version-specific parsers, indexed by (version - 1). diff --git a/app/src/terminal/cli_agent_sessions/event/v1.rs b/app/src/terminal/cli_agent_sessions/event/v1.rs index a1441a767c..ae7077b491 100644 --- a/app/src/terminal/cli_agent_sessions/event/v1.rs +++ b/app/src/terminal/cli_agent_sessions/event/v1.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType}; +use super::{CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType}; use crate::terminal::CLIAgent; /// Resolves a CLI agent from the `"agent"` string in a CLI agent event. @@ -54,6 +54,7 @@ pub(super) fn parse(body: &str) -> Option { tool_input_preview, plugin_version: raw.plugin_version, }, + source: CLIAgentEventSource::RichPlugin, }) } diff --git a/app/src/terminal/cli_agent_sessions/listener/mod.rs b/app/src/terminal/cli_agent_sessions/listener/mod.rs index e93b05e715..267cad8611 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod.rs @@ -3,7 +3,7 @@ use warpui::{EntityId, ModelContext, ModelHandle, SingletonEntity}; use super::{CLIAgentEvent, CLIAgentSessionsModel}; use crate::features::FeatureFlag; use crate::terminal::cli_agent_sessions::event::{ - parse_event, CLIAgentEventPayload, CLIAgentEventType, + parse_event, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, }; use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::CLIAgent; @@ -16,7 +16,17 @@ trait CLIAgentSessionHandler { /// The default implementation delegates to the structured JSON parser /// (`parse_event`); agents with non-JSON notification formats (e.g. Codex /// OSC 9 plain text) should override this. - fn try_parse(&mut self, title: Option<&str>, body: &str) -> Option { + /// + /// `plugin_already_active` is true when the session has already received a + /// structured OSC 777 notification; Codex uses it to drop OSC 9 fallback + /// once the rich plugin is active. Other handlers ignore it. + fn try_parse( + &mut self, + title: Option<&str>, + body: &str, + plugin_already_active: bool, + ) -> Option { + let _ = plugin_already_active; parse_event(title, body) } @@ -51,7 +61,7 @@ fn create_handler(agent: &CLIAgent) -> Option> { | CLIAgent::Gemini | CLIAgent::Auggie | CLIAgent::Pi => Some(Box::new(DefaultSessionListener)), - CLIAgent::Codex => Some(Box::new(CodexSessionHandler::default())), + CLIAgent::Codex => Some(Box::new(CodexSessionHandler)), CLIAgent::Hermes | CLIAgent::Amp | CLIAgent::Droid @@ -83,11 +93,7 @@ impl CLIAgentSessionHandler for DefaultSessionListener { /// Codex sends notifications via OSC 9 (`\x1b]9;message\x07`) with /// human-readable text. Since there's no way to distinguish notification types from the raw text, /// OSC 9 fallback notifications are treated as `Stop` (success). -#[derive(Default)] -struct CodexSessionHandler { - /// Whether we are using a plugin with OSC777 events or falling back to OSC9. - structured_plugin_active: bool, -} +struct CodexSessionHandler; impl CodexSessionHandler { /// Parse a plain-text OSC 9 notification body into a `CLIAgentEvent`. @@ -109,6 +115,7 @@ impl CodexSessionHandler { query: Some(body.to_owned()), ..Default::default() }, + source: CLIAgentEventSource::CodexOsc9Fallback, }) } } @@ -118,20 +125,24 @@ impl CLIAgentSessionHandler for CodexSessionHandler { /// Here, we try to parse an OSC 777 event if we can, and remember when we've seen one. /// This lets us ignore OSC 9 notifications if we are working with a client that is using /// the new plugin, but keeps them intact for legacy clients. - fn try_parse(&mut self, title: Option<&str>, body: &str) -> Option { + fn try_parse( + &mut self, + title: Option<&str>, + body: &str, + plugin_already_active: bool, + ) -> Option { if let Some(event) = parse_event(title, body) { if event.agent == CLIAgent::Codex { if !FeatureFlag::CodexPlugin.is_enabled() { return None; } - self.structured_plugin_active = true; return Some(event); } return None; } - // OSC 9 notifications have no title. Also skip OSC 9 processing if the plugin is active, otherwise - // we'd process both OSC 777 and OSC 9 notifications. - if title.is_some() || self.structured_plugin_active { + // OSC 9 notifications have no title. Skip OSC 9 once the rich plugin is + // active, otherwise we'd process both OSC 777 and OSC 9 notifications. + if title.is_some() || plugin_already_active { return None; } Self::parse_osc9_text(body) @@ -169,12 +180,19 @@ impl CLIAgentSessionListener { // `handle_event` then filters/transforms the result. ctx.subscribe_to_model(model_event_dispatcher, move |me, event, ctx| { if let ModelEvent::PluggableNotification { title, body } = event { - let Some(parsed) = me.inner.try_parse(title.as_deref(), body) else { + let view_id = me.terminal_view_id; + let plugin_already_active = CLIAgentSessionsModel::as_ref(ctx) + .session(view_id) + .is_some_and(|session| session.received_rich_notification); + let Some(parsed) = + me.inner + .try_parse(title.as_deref(), body, plugin_already_active) + else { return; }; if let Some(event) = me.inner.handle_event(parsed) { CLIAgentSessionsModel::handle(ctx).update(ctx, |sessions_model, ctx| { - sessions_model.update_from_event(me.terminal_view_id, &event, ctx); + sessions_model.update_from_event(view_id, &event, ctx); }); } } diff --git a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs index 5d0582b4c7..23fecf78d2 100644 --- a/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/listener/mod_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::terminal::cli_agent_sessions::event::{ - CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL, + CLIAgentEventSource, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL, }; #[test] @@ -42,54 +42,63 @@ fn codex_ignores_empty_body() { #[test] fn codex_try_parse_ignores_titled_notifications() { - let mut handler = CodexSessionHandler::default(); + let mut handler = CodexSessionHandler; assert!(handler - .try_parse(Some("some-title"), "Agent turn complete") + .try_parse(Some("some-title"), "Agent turn complete", false) .is_none()); } #[test] fn codex_try_parse_handles_osc9() { - let mut handler = CodexSessionHandler::default(); - let event = handler.try_parse(None, "Agent turn complete").unwrap(); + let mut handler = CodexSessionHandler; + let event = handler + .try_parse(None, "Agent turn complete", false) + .unwrap(); assert_eq!(event.event, CLIAgentEventType::Stop); } #[test] -fn codex_try_parse_ignores_osc9_after_structured_event() { +fn codex_try_parse_ignores_osc9_when_plugin_already_active() { let _guard = FeatureFlag::CodexPlugin.override_enabled(true); - let mut handler = CodexSessionHandler::default(); + let mut handler = CodexSessionHandler; let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#; let event = handler - .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body) + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false) .unwrap(); assert_eq!(event.event, CLIAgentEventType::PermissionRequest); - assert!(handler.try_parse(None, "Agent turn complete").is_none()); + // Once the session is rich, OSC 9 fallback is dropped. + assert!(handler + .try_parse(None, "Agent turn complete", true) + .is_none()); } #[test] fn codex_try_parse_ignores_structured_event_without_codex_plugin() { let _guard = FeatureFlag::CodexPlugin.override_enabled(false); - let mut handler = CodexSessionHandler::default(); + let mut handler = CodexSessionHandler; let body = r#"{"v":1,"agent":"codex","event":"permission_request","summary":"Approve?","tool_name":"Bash"}"#; assert!(handler - .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body) + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false) .is_none()); - assert!(handler.try_parse(None, "Agent turn complete").is_some()); + assert!(handler + .try_parse(None, "Agent turn complete", false) + .is_some()); } #[test] fn codex_try_parse_ignores_other_structured_agents() { - let mut handler = CodexSessionHandler::default(); + let mut handler = CodexSessionHandler; let body = r#"{"v":1,"agent":"claude","event":"stop"}"#; assert!(handler - .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body) + .try_parse(Some(CLI_AGENT_NOTIFICATION_SENTINEL), body, false) .is_none()); - assert!(handler.try_parse(None, "Agent turn complete").is_some()); + assert!(handler + .try_parse(None, "Agent turn complete", false) + .is_some()); } #[test] @@ -101,6 +110,7 @@ fn auggie_is_supported() { fn auggie_default_handler_skips_session_start() { let mut handler = DefaultSessionListener; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Auggie, event: CLIAgentEventType::SessionStart, @@ -116,6 +126,7 @@ fn auggie_default_handler_skips_session_start() { fn auggie_default_handler_forwards_stop() { let mut handler = DefaultSessionListener; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Auggie, event: CLIAgentEventType::Stop, @@ -136,6 +147,7 @@ fn pi_is_supported() { fn pi_default_handler_skips_session_start() { let mut handler = DefaultSessionListener; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Pi, event: CLIAgentEventType::SessionStart, @@ -151,6 +163,7 @@ fn pi_default_handler_skips_session_start() { fn pi_default_handler_forwards_stop() { let mut handler = DefaultSessionListener; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Pi, event: CLIAgentEventType::Stop, diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index d07e1c42bd..13a4663651 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -5,7 +5,7 @@ pub(crate) mod plugin_manager; use std::collections::{HashMap, HashSet}; -use event::{CLIAgentEvent, CLIAgentEventType}; +use event::{CLIAgentEvent, CLIAgentEventSource, CLIAgentEventType}; use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use self::listener::CLIAgentSessionListener; @@ -140,6 +140,10 @@ pub struct CLIAgentSession { /// the first word of the command (the binary/alias the user typed). /// Used to customize plugin instructions and force manual install mode. pub custom_command_prefix: Option, + /// Set once the session has received any structured OSC 777 (rich) + /// notification. Codex's OSC 9 fallback never sets it, so this is the + /// single source of truth for whether the session is plugin-backed. + pub received_rich_notification: bool, } impl CLIAgentSession { @@ -147,22 +151,14 @@ impl CLIAgentSession { self.remote_host.is_some() } - /// Whether this session is backed by a connected structured plugin (rich - /// OSC 777 events) rather than command detection or Codex's OSC 9 fallback. - /// Codex's structured plugin always reports a version on connect, so a - /// present `plugin_version` distinguishes it from the versionless OSC 9 - /// fallback. Other agents only ever connect via the structured plugin. - pub fn has_structured_plugin(&self) -> bool { - self.listener.is_some() - && (!matches!(self.agent, CLIAgent::Codex) || self.plugin_version.is_some()) - } - /// Whether the session surfaces trustworthy fine-grained status - /// (in-progress / blocked / success). True exactly when a structured - /// plugin is connected; Codex's OSC 9 fallback only emits opaque `Stop` - /// notifications and so does not qualify. + /// (in-progress / blocked / success). True only after receiving a rich OSC + /// 777 notification. Codex's OSC 9 fallback emits only opaque `Stop` + /// notifications and never sets `received_rich_notification`, so it does + /// not qualify. Synthetic listener registration also does not qualify until + /// an actual rich notification arrives. pub fn supports_rich_status(&self) -> bool { - self.has_structured_plugin() + self.received_rich_notification } /// Clears state populated by `PermissionRequest`. Called whenever the @@ -395,6 +391,7 @@ impl CLIAgentSessionsModel { remote_host, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -410,6 +407,8 @@ impl CLIAgentSessionsModel { } /// Updates the session's status and context from a parsed CLI agent event. + /// Rich plugin events latch `received_rich_notification` so rich-status + /// surfaces stay consistent even if the first event was not SessionStart. pub fn update_from_event( &mut self, terminal_view_id: EntityId, @@ -420,6 +419,10 @@ impl CLIAgentSessionsModel { return; }; + if event.source == CLIAgentEventSource::RichPlugin { + session.received_rich_notification = true; + } + let event_type = &event.event; if let Some(new_status) = session.apply_event(event) { let agent = session.agent; diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index 2535893b27..20da25ad5c 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -1,4 +1,6 @@ -use super::event::{parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType}; +use super::event::{ + parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, +}; use super::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, @@ -257,9 +259,11 @@ fn apply_event_preserves_input_session() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -290,6 +294,7 @@ fn is_remote_returns_true_when_remote_host_is_set() { draft_text: None, remote_host: Some("user@devbox".to_owned()), custom_command_prefix: None, + received_rich_notification: false, }; assert!(session.is_remote()); } @@ -307,6 +312,7 @@ fn is_remote_returns_false_when_remote_host_is_none() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }; assert!(!session.is_remote()); } @@ -375,9 +381,11 @@ fn session_start_sets_plugin_version() { draft_text: None, remote_host: None, custom_command_prefix: None, + received_rich_notification: false, }; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::SessionStart, @@ -407,9 +415,11 @@ fn session_start_without_plugin_version_leaves_none() { draft_text: None, remote_host: None, custom_command_prefix: None, + received_rich_notification: false, }; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::SessionStart, @@ -423,6 +433,52 @@ fn session_start_without_plugin_version_leaves_none() { assert_eq!(session.plugin_version, None); } +#[test] +fn codex_session_not_rich_until_rich_notification() { + // Codex's OSC 9 fallback never sets `received_rich_notification`, so the + // session must not claim rich status even when a fallback listener exists. + let mut session = CLIAgentSession { + agent: CLIAgent::Codex, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext::default(), + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: None, + plugin_version: None, + remote_host: None, + draft_text: None, + custom_command_prefix: None, + received_rich_notification: false, + }; + assert!(!session.supports_rich_status()); + + // A structured OSC 777 notification latches the flag -> rich status. + session.received_rich_notification = true; + assert!(session.supports_rich_status()); +} + +#[test] +fn non_codex_session_rich_after_rich_notification() { + let mut session = CLIAgentSession { + agent: CLIAgent::Claude, + status: CLIAgentSessionStatus::InProgress, + session_context: CLIAgentSessionContext::default(), + input_state: CLIAgentInputState::Closed, + should_auto_toggle_input: false, + listener: None, + plugin_version: None, + remote_host: None, + draft_text: None, + custom_command_prefix: None, + received_rich_notification: false, + }; + // No listener and no rich notification yet. + assert!(!session.supports_rich_status()); + + session.received_rich_notification = true; + assert!(session.supports_rich_status()); +} + /// Constructs a session with permission-scoped state already populated, as if /// a `PermissionRequest` had just been received and the agent is now Blocked. /// Used by the GH-9525 regression tests below. @@ -445,6 +501,7 @@ fn blocked_claude_session_with_permission_state() -> CLIAgentSession { draft_text: None, remote_host: None, custom_command_prefix: None, + received_rich_notification: false, } } @@ -456,6 +513,7 @@ fn stop_clears_permission_scoped_state() { let mut session = blocked_claude_session_with_permission_state(); let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::Stop, @@ -493,6 +551,7 @@ fn permission_replied_clears_permission_scoped_state() { let mut session = blocked_claude_session_with_permission_state(); let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, @@ -520,6 +579,7 @@ fn prompt_submit_clears_permission_scoped_state() { session.session_context.response = Some("stale response".to_owned()); let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PromptSubmit, @@ -560,9 +620,11 @@ fn permission_request_still_populates_summary_and_tool_fields() { draft_text: None, remote_host: None, custom_command_prefix: None, + received_rich_notification: false, }; let event = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs index c5de323e83..e6822a16b1 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/codex_tests.rs @@ -1,7 +1,8 @@ +use std::fs; + use super::CodexPluginManager; use crate::features::FeatureFlag; use crate::terminal::cli_agent_sessions::plugin_manager::CliAgentPluginManager; -use std::fs; #[test] fn can_auto_install_is_true() { diff --git a/app/src/terminal/shared_session/shared_handlers.rs b/app/src/terminal/shared_session/shared_handlers.rs index bce693096f..25f4f12dd0 100644 --- a/app/src/terminal/shared_session/shared_handlers.rs +++ b/app/src/terminal/shared_session/shared_handlers.rs @@ -388,6 +388,7 @@ pub(crate) fn apply_cli_agent_state_update( remote_host: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, // Viewer input is managed by the sync protocol, // not local status-change auto-toggle. should_auto_toggle_input: false, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 1f3a9ab01b..6c1a8932c4 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -399,7 +399,7 @@ use crate::terminal::block_list_viewport::{ }; use crate::terminal::bootstrap::init_subshell_command; use crate::terminal::cli_agent_sessions::event::{ - parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, + parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, CLI_AGENT_NOTIFICATION_SENTINEL, }; use crate::terminal::cli_agent_sessions::listener::{is_agent_supported, CLIAgentSessionListener}; @@ -11804,6 +11804,7 @@ impl TerminalView { remote_host, draft_text: None, custom_command_prefix: custom_command_prefix.clone(), + received_rich_notification: false, }, ctx, ); @@ -12978,6 +12979,7 @@ impl TerminalView { #[cfg(target_family = "wasm")] let plugin_version = None; let notification = CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent, event: CLIAgentEventType::SessionStart, diff --git a/app/src/terminal/view/use_agent_footer/mod_tests.rs b/app/src/terminal/view/use_agent_footer/mod_tests.rs index 14b4f32169..19c50f2f60 100644 --- a/app/src/terminal/view/use_agent_footer/mod_tests.rs +++ b/app/src/terminal/view/use_agent_footer/mod_tests.rs @@ -364,6 +364,7 @@ fn cli_agent_footer_renders_for_viewer_of_shared_cloud_agent_session() { remote_host: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, should_auto_toggle_input: false, }, ctx, diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index ae479ff8be..e6bbd4b2f3 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -51,7 +51,7 @@ use crate::terminal::alt_screen::should_intercept_mouse; use crate::terminal::block_list_element::{SnackbarPoint, SnackbarTranslationMode}; use crate::terminal::block_list_viewport::{ClampingMode, ScrollLines}; use crate::terminal::cli_agent_sessions::event::{ - CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, + CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventSource, CLIAgentEventType, }; use crate::terminal::cli_agent_sessions::listener::CLIAgentSessionListener; use crate::terminal::cli_agent_sessions::{ @@ -422,6 +422,7 @@ fn submit_cli_agent_rich_input_restores_unlocked_input_config() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -488,6 +489,7 @@ fn unregister_cli_agent_session_restores_unlocked_input_config() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5129,6 +5131,7 @@ fn submit_rich_input_and_collect_pty_writes( plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5167,6 +5170,7 @@ fn open_cli_agent_rich_input_for_agent_with_window_id( plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5489,6 +5493,7 @@ fn drag_drop_image_in_cli_agent_long_running_command_pastes_via_clipboard() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5564,6 +5569,7 @@ fn paste_raw_image_clipboard_in_cli_agent_sends_correct_bytes() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5641,6 +5647,7 @@ fn submit_without_auto_dismiss_keeps_rich_input_open() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5703,6 +5710,7 @@ fn submit_with_plugin_and_auto_toggle_keeps_rich_input_open() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: true, }, ctx, ); @@ -5757,6 +5765,7 @@ fn submit_with_plugin_but_auto_toggle_off_respects_auto_dismiss() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5811,6 +5820,7 @@ fn status_blocked_auto_closes_rich_input() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5824,6 +5834,7 @@ fn status_blocked_auto_closes_rich_input() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -5886,6 +5897,7 @@ fn status_in_progress_auto_opens_rich_input_after_blocked() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5897,6 +5909,7 @@ fn status_in_progress_auto_opens_rich_input_after_blocked() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -5924,6 +5937,7 @@ fn status_in_progress_auto_opens_rich_input_after_blocked() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, @@ -5982,6 +5996,7 @@ fn codex_status_change_does_not_auto_open_rich_input() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -5995,6 +6010,7 @@ fn codex_status_change_does_not_auto_open_rich_input() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::CodexOsc9Fallback, v: 1, agent: CLIAgent::Codex, event: CLIAgentEventType::Stop, @@ -6062,6 +6078,7 @@ fn cli_session_status_updates_active_child_conversation() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -6082,6 +6099,7 @@ fn cli_session_status_updates_active_child_conversation() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -6115,6 +6133,7 @@ fn cli_session_status_updates_active_child_conversation() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied, @@ -6140,6 +6159,7 @@ fn cli_session_status_updates_active_child_conversation() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::Stop, @@ -6203,6 +6223,7 @@ fn cli_session_status_updates_single_child_conversation_without_agent_view() { plugin_version: None, draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -6223,6 +6244,7 @@ fn cli_session_status_updates_single_child_conversation_without_agent_view() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::Stop, @@ -6280,6 +6302,7 @@ fn manual_dismiss_disables_auto_toggle_for_session() { plugin_version: Some("1.0.0".to_owned()), draft_text: None, custom_command_prefix: None, + received_rich_notification: false, }, ctx, ); @@ -6306,6 +6329,7 @@ fn manual_dismiss_disables_auto_toggle_for_session() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionRequest, @@ -6324,6 +6348,7 @@ fn manual_dismiss_disables_auto_toggle_for_session() { sessions.update_from_event( view.view_id, &CLIAgentEvent { + source: CLIAgentEventSource::RichPlugin, v: 1, agent: CLIAgent::Claude, event: CLIAgentEventType::PermissionReplied,