From d4d36ffb842d7495152c24663e1a437739006a23 Mon Sep 17 00:00:00 2001 From: Akshey Deokule Date: Tue, 15 Sep 2026 03:07:04 +0000 Subject: [PATCH] feat(cli-agent): notify CLI agents when rich input opens and closes Add a per-pane control endpoint (Unix domain socket, Windows named pipe) advertised via WARP_CLI_AGENT_CONTROL_SOCKET. Warp writes one JSON line on connect and on each rich input open/close transition so a CLI agent can hide its own prompt bar while Warp's composer is active. --- .../cli_agent_sessions/control_endpoint.rs | 299 ++++++++++++++++++ .../control_endpoint_tests.rs | 206 ++++++++++++ app/src/terminal/cli_agent_sessions/mod.rs | 2 + .../terminal/local_tty/terminal_manager.rs | 61 +++- crates/warp_core/src/cli_agent_protocol.rs | 33 ++ 5 files changed, 599 insertions(+), 2 deletions(-) create mode 100644 app/src/terminal/cli_agent_sessions/control_endpoint.rs create mode 100644 app/src/terminal/cli_agent_sessions/control_endpoint_tests.rs diff --git a/app/src/terminal/cli_agent_sessions/control_endpoint.rs b/app/src/terminal/cli_agent_sessions/control_endpoint.rs new file mode 100644 index 00000000000..3cc78fdf5bd --- /dev/null +++ b/app/src/terminal/cli_agent_sessions/control_endpoint.rs @@ -0,0 +1,299 @@ +//! Pane-scoped endpoint that publishes Warp-to-agent control events (currently rich input +//! visibility) to local CLI agent processes as UTF-8 JSON Lines. + +use std::ffi::{OsStr, OsString}; +use std::future::Future; +use std::io; +use std::sync::Arc; +use std::time::Duration; + +use async_compat::Compat; +use parking_lot::Mutex; +use tokio::io::{AsyncWrite, AsyncWriteExt as _}; +use uuid::Uuid; +use warp_core::cli_agent_protocol::CLIAgentControlEvent; +use warpui::r#async::executor::{Background, BackgroundTask}; + +use super::{CLIAgentInputState, CLIAgentSessionsModelEvent}; + +/// Simultaneously connected clients per pane; further connections are closed on accept. +const MAX_CLIENTS: usize = 16; +/// Lines queued per client before it is treated as stalled and dropped, so a client that stopped +/// reading never back-pressures the publisher. +const CLIENT_QUEUE_CAPACITY: usize = 8; +/// Upper bound on a single client write before the client is dropped. +const CLIENT_WRITE_TIMEOUT: Duration = Duration::from_secs(5); +/// Hex chars kept from the pane's random id, short enough for macOS's 104-byte socket path limit. +const SHORT_ID_LEN: usize = 16; + +struct EndpointState { + rich_input_active: bool, + clients: Vec>>, + closed: bool, +} + +/// Cloneable handle that broadcasts state changes to the endpoint's connected clients. +#[derive(Clone)] +pub(crate) struct CLIAgentControlPublisher { + state: Arc>, +} + +impl CLIAgentControlPublisher { + /// Mirrors a session model event for this pane onto the control channel. + pub(crate) fn observe(&self, event: &CLIAgentSessionsModelEvent) { + match event { + CLIAgentSessionsModelEvent::InputSessionChanged { + new_input_state, .. + } => self.set_rich_input_active(matches!( + new_input_state, + CLIAgentInputState::Open { .. } + )), + CLIAgentSessionsModelEvent::Ended { .. } => self.set_rich_input_active(false), + CLIAgentSessionsModelEvent::Started { .. } + | CLIAgentSessionsModelEvent::StatusChanged { .. } + | CLIAgentSessionsModelEvent::SessionUpdated { .. } => {} + } + } + + /// Publishes a `rich_input` event if `active` differs from the last published state. + pub(crate) fn set_rich_input_active(&self, active: bool) { + let mut state = self.state.lock(); + if state.rich_input_active == active { + return; + } + state.rich_input_active = active; + let line = rich_input_line(active); + state + .clients + .retain(|client| client.try_send(line.clone()).is_ok()); + } +} + +/// Owns one pane's listener and client connections; dropping it closes all of them. +pub(crate) struct CLIAgentControlEndpoint { + address: OsString, + publisher: CLIAgentControlPublisher, + accept_task: BackgroundTask, +} + +impl CLIAgentControlEndpoint { + /// Binds a new endpoint with rich input reported as inactive and starts accepting clients + /// on `executor`. + pub(crate) fn bind(executor: Arc) -> io::Result { + let id = Uuid::new_v4().simple().to_string(); + let address = platform::address(&id[..SHORT_ID_LEN])?; + let listener = platform::bind(&address)?; + let state = Arc::new(Mutex::new(EndpointState { + rich_input_active: false, + clients: Vec::new(), + closed: false, + })); + let accept_task = executor.spawn(accept_loop( + listener, + address.clone(), + state.clone(), + executor.clone(), + )); + Ok(Self { + address, + publisher: CLIAgentControlPublisher { state }, + accept_task, + }) + } + + /// Address CLI agents connect to: a socket path on Unix, a named pipe path on Windows. + pub(crate) fn address(&self) -> &OsStr { + &self.address + } + + pub(crate) fn publisher(&self) -> CLIAgentControlPublisher { + self.publisher.clone() + } +} + +impl Drop for CLIAgentControlEndpoint { + fn drop(&mut self) { + self.accept_task.abort(); + self.publisher.set_rich_input_active(false); + { + let mut state = self.publisher.state.lock(); + state.closed = true; + // Dropping the senders lets each writer flush what is queued and then close its + // stream. + state.clients.clear(); + } + #[cfg(unix)] + let _ = std::fs::remove_file(&self.address); + } +} + +fn rich_input_line(active: bool) -> Arc { + let mut line = serde_json::to_string(&CLIAgentControlEvent::rich_input(active)) + .expect("control event serializes to JSON"); + line.push('\n'); + line.into() +} + +async fn accept_loop( + mut listener: platform::Listener, + address: OsString, + state: Arc>, + executor: Arc, +) { + loop { + let stream = match listener.accept().await { + Ok(stream) => stream, + Err(err) => { + log::warn!( + "CLI agent control endpoint {} stopped accepting clients: {err}", + address.display() + ); + return; + } + }; + let (tx, rx) = async_channel::bounded(CLIENT_QUEUE_CAPACITY); + { + let mut state = state.lock(); + state.clients.retain(|client| !client.is_closed()); + if state.closed || state.clients.len() >= MAX_CLIENTS { + continue; + } + // Queued before the client is visible to the publisher, so it always observes the + // current state first. + let _ = tx.try_send(rich_input_line(state.rich_input_active)); + state.clients.push(tx); + } + executor.spawn(write_loop(stream, rx)).detach(); + } +} + +async fn write_loop( + mut stream: impl AsyncWrite + Unpin, + lines: async_channel::Receiver>, +) { + while let Ok(line) = lines.recv().await { + match tokio::time::timeout(CLIENT_WRITE_TIMEOUT, stream.write_all(line.as_bytes())).await { + Ok(Ok(())) => {} + Ok(Err(_)) | Err(_) => return, + } + } +} + +/// Prefixes an I/O error with the path it concerns. +fn address_error(address: &OsStr, err: io::Error) -> io::Error { + io::Error::new(err.kind(), format!("{}: {err}", address.display())) +} + +/// Tokio I/O resources register with the reactor on creation, which requires a tokio context. +fn in_tokio_context(future: impl Future) -> T { + warpui::r#async::block_on(Compat::new(future)) +} + +#[cfg(unix)] +mod platform { + use std::ffi::{OsStr, OsString}; + use std::fs::Permissions; + use std::io; + use std::os::unix::fs::PermissionsExt as _; + use std::os::unix::net::UnixListener as StdUnixListener; + use std::path::{Path, PathBuf}; + + use tokio::net::{UnixListener, UnixStream}; + + use super::{address_error, in_tokio_context}; + + pub(super) type Stream = UnixStream; + + pub(super) struct Listener(UnixListener); + + fn socket_dir() -> io::Result { + match std::env::var_os("XDG_RUNTIME_DIR") { + Some(runtime_dir) if !runtime_dir.is_empty() => { + let dir = PathBuf::from(runtime_dir).join("warp"); + std::fs::create_dir_all(&dir) + .and_then(|()| std::fs::set_permissions(&dir, Permissions::from_mode(0o700))) + .map_err(|err| address_error(dir.as_os_str(), err))?; + Ok(dir) + } + Some(_) | None => Ok(std::env::temp_dir()), + } + } + + pub(super) fn address(id: &str) -> io::Result { + Ok(socket_dir()?.join(format!("ca-{id}.sock")).into_os_string()) + } + + pub(super) fn bind(address: &OsStr) -> io::Result { + let path = Path::new(address); + let listener = StdUnixListener::bind(path).map_err(|err| address_error(address, err))?; + let listener = register(path, listener).map_err(|err| { + let _ = std::fs::remove_file(path); + address_error(address, err) + })?; + Ok(Listener(listener)) + } + + fn register(path: &Path, listener: StdUnixListener) -> io::Result { + std::fs::set_permissions(path, Permissions::from_mode(0o600))?; + listener.set_nonblocking(true)?; + in_tokio_context(async { UnixListener::from_std(listener) }) + } + + impl Listener { + pub(super) async fn accept(&mut self) -> io::Result { + self.0.accept().await.map(|(stream, _)| stream) + } + } +} + +#[cfg(windows)] +mod platform { + use std::ffi::{OsStr, OsString}; + use std::io; + + use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; + use warp_core::channel::ChannelState; + + use super::{address_error, in_tokio_context}; + + pub(super) type Stream = NamedPipeServer; + + /// The pipe instance waiting for the next client; a fresh instance replaces it on accept. + pub(super) struct Listener { + address: OsString, + server: NamedPipeServer, + } + + pub(super) fn address(id: &str) -> io::Result { + Ok(format!( + r"\\.\pipe\Warp{:?}_cli_agent_control_{id}", + ChannelState::channel() + ) + .into()) + } + + pub(super) fn bind(address: &OsStr) -> io::Result { + let server = in_tokio_context(async { + ServerOptions::new() + .first_pipe_instance(true) + .create(address) + }) + .map_err(|err| address_error(address, err))?; + Ok(Listener { + address: address.to_owned(), + server, + }) + } + + impl Listener { + pub(super) async fn accept(&mut self) -> io::Result { + self.server.connect().await?; + let next = ServerOptions::new().create(&self.address)?; + Ok(std::mem::replace(&mut self.server, next)) + } + } +} + +#[cfg(test)] +#[path = "control_endpoint_tests.rs"] +mod tests; diff --git a/app/src/terminal/cli_agent_sessions/control_endpoint_tests.rs b/app/src/terminal/cli_agent_sessions/control_endpoint_tests.rs new file mode 100644 index 00000000000..d8502d4c7d8 --- /dev/null +++ b/app/src/terminal/cli_agent_sessions/control_endpoint_tests.rs @@ -0,0 +1,206 @@ +use std::sync::Arc; +#[cfg(unix)] +use std::time::Duration; + +use parking_lot::Mutex; +#[cfg(unix)] +use tokio::io::{AsyncBufReadExt as _, BufReader, Lines}; +#[cfg(unix)] +use tokio::net::UnixStream; +use warpui::EntityId; +#[cfg(unix)] +use warpui::r#async::executor::Background; + +use super::*; +use crate::ai::blocklist::{InputConfig, InputType}; +use crate::terminal::CLIAgent; +use crate::terminal::cli_agent_sessions::CLIAgentInputEntrypoint; + +const ACTIVE_JSON: &str = r#"{"v":1,"event":"rich_input","active":true}"#; +const INACTIVE_JSON: &str = r#"{"v":1,"event":"rich_input","active":false}"#; + +fn test_publisher() -> (CLIAgentControlPublisher, async_channel::Receiver>) { + let (tx, rx) = async_channel::bounded(CLIENT_QUEUE_CAPACITY); + let state = Arc::new(Mutex::new(EndpointState { + rich_input_active: false, + clients: vec![tx], + closed: false, + })); + (CLIAgentControlPublisher { state }, rx) +} + +fn open_state() -> CLIAgentInputState { + CLIAgentInputState::Open { + entrypoint: CLIAgentInputEntrypoint::CtrlG, + previous_input_config: InputConfig { + input_type: InputType::Shell, + is_locked: false, + }, + previous_was_lock_set_with_empty_buffer: false, + } +} + +fn input_session_changed( + terminal_view_id: EntityId, + previous_input_state: CLIAgentInputState, + new_input_state: CLIAgentInputState, +) -> CLIAgentSessionsModelEvent { + CLIAgentSessionsModelEvent::InputSessionChanged { + terminal_view_id, + agent: CLIAgent::Claude, + previous_input_state, + new_input_state, + } +} + +#[cfg(unix)] +fn test_background() -> Arc { + Arc::new(Background::new(1, |_| "cli-agent-control-test".to_owned())) +} + +#[cfg(unix)] +async fn connect(endpoint: &CLIAgentControlEndpoint) -> Lines> { + let stream = UnixStream::connect(endpoint.address()).await.unwrap(); + BufReader::new(stream).lines() +} + +/// Reads the next control event, or `None` once the endpoint closed the connection. +#[cfg(unix)] +async fn next_line(lines: &mut Lines>) -> Option { + tokio::time::timeout(Duration::from_secs(5), lines.next_line()) + .await + .expect("timed out waiting for a control event") + .unwrap() +} + +#[test] +fn rich_input_line_is_versioned_json_line() { + assert_eq!(&*rich_input_line(true), format!("{ACTIVE_JSON}\n")); + assert_eq!(&*rich_input_line(false), format!("{INACTIVE_JSON}\n")); +} + +#[test] +fn publisher_sends_one_line_per_transition() { + let (publisher, rx) = test_publisher(); + + publisher.set_rich_input_active(false); + assert!(rx.try_recv().is_err(), "closed -> closed publishes nothing"); + + publisher.set_rich_input_active(true); + publisher.set_rich_input_active(true); + assert_eq!(&*rx.try_recv().unwrap(), format!("{ACTIVE_JSON}\n")); + assert!(rx.try_recv().is_err(), "open -> open publishes nothing"); + + publisher.set_rich_input_active(false); + assert_eq!(&*rx.try_recv().unwrap(), format!("{INACTIVE_JSON}\n")); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn observe_maps_input_session_transitions() { + let (publisher, rx) = test_publisher(); + let terminal_view_id = EntityId::new(); + + publisher.observe(&input_session_changed( + terminal_view_id, + CLIAgentInputState::Closed, + open_state(), + )); + assert_eq!(&*rx.try_recv().unwrap(), format!("{ACTIVE_JSON}\n")); + + publisher.observe(&input_session_changed( + terminal_view_id, + open_state(), + open_state(), + )); + assert!(rx.try_recv().is_err(), "open -> open publishes nothing"); + + publisher.observe(&input_session_changed( + terminal_view_id, + open_state(), + CLIAgentInputState::Closed, + )); + assert_eq!(&*rx.try_recv().unwrap(), format!("{INACTIVE_JSON}\n")); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn observe_ended_while_active_publishes_inactive() { + let (publisher, rx) = test_publisher(); + let terminal_view_id = EntityId::new(); + + publisher.observe(&input_session_changed( + terminal_view_id, + CLIAgentInputState::Closed, + open_state(), + )); + assert_eq!(&*rx.try_recv().unwrap(), format!("{ACTIVE_JSON}\n")); + + publisher.observe(&CLIAgentSessionsModelEvent::Ended { + terminal_view_id, + agent: CLIAgent::Claude, + }); + assert_eq!(&*rx.try_recv().unwrap(), format!("{INACTIVE_JSON}\n")); + assert!(rx.try_recv().is_err()); +} + +#[cfg(unix)] +#[test] +fn bound_unix_socket_is_owner_only_and_short() { + use std::os::unix::fs::PermissionsExt as _; + + let endpoint = CLIAgentControlEndpoint::bind(test_background()).unwrap(); + let mode = std::fs::metadata(endpoint.address()) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + #[cfg(target_os = "macos")] + assert!(endpoint.address().len() < 104, "{:?}", endpoint.address()); +} + +#[cfg(unix)] +#[tokio::test] +async fn new_client_receives_current_state_on_connect() { + let endpoint = CLIAgentControlEndpoint::bind(test_background()).unwrap(); + + let mut connected_while_closed = connect(&endpoint).await; + assert_eq!( + next_line(&mut connected_while_closed).await.as_deref(), + Some(INACTIVE_JSON) + ); + + endpoint.publisher().set_rich_input_active(true); + let mut connected_while_open = connect(&endpoint).await; + assert_eq!( + next_line(&mut connected_while_open).await.as_deref(), + Some(ACTIVE_JSON) + ); + assert_eq!( + next_line(&mut connected_while_closed).await.as_deref(), + Some(ACTIVE_JSON) + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn broadcast_survives_client_disconnect_and_ends_on_drop() { + let endpoint = CLIAgentControlEndpoint::bind(test_background()).unwrap(); + let publisher = endpoint.publisher(); + + let mut first = connect(&endpoint).await; + let mut second = connect(&endpoint).await; + assert_eq!(next_line(&mut first).await.as_deref(), Some(INACTIVE_JSON)); + assert_eq!(next_line(&mut second).await.as_deref(), Some(INACTIVE_JSON)); + + publisher.set_rich_input_active(true); + assert_eq!(next_line(&mut first).await.as_deref(), Some(ACTIVE_JSON)); + assert_eq!(next_line(&mut second).await.as_deref(), Some(ACTIVE_JSON)); + + drop(first); + publisher.set_rich_input_active(false); + assert_eq!(next_line(&mut second).await.as_deref(), Some(INACTIVE_JSON)); + + drop(endpoint); + assert_eq!(next_line(&mut second).await, None); +} diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index 5475fb05a4c..0eec756bfc4 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "local_tty")] +pub(crate) mod control_endpoint; pub mod event; pub mod listener; #[cfg(not(target_family = "wasm"))] diff --git a/app/src/terminal/local_tty/terminal_manager.rs b/app/src/terminal/local_tty/terminal_manager.rs index 6c5a9b26154..381f548b32b 100644 --- a/app/src/terminal/local_tty/terminal_manager.rs +++ b/app/src/terminal/local_tty/terminal_manager.rs @@ -17,9 +17,10 @@ use parking_lot::{FairMutex, Mutex}; use pathfinder_geometry::vector::Vector2F; use settings::Setting as _; use warp_core::SessionId; +use warp_core::cli_agent_protocol::WARP_CLI_AGENT_CONTROL_SOCKET_ENV; use warp_errors::report_error; use warpui::r#async::executor::Background; -use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, ViewHandle}; +use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, ViewHandle}; use super::event_loop::EventLoop; use super::shell::{ShellStarter, ShellStarterSource}; @@ -40,6 +41,8 @@ use crate::send_telemetry_on_executor; use crate::server::telemetry::{PtySpawnMode as TelemetryPtySpawnMode, TelemetryEvent}; use crate::settings::{DebugSettings, PrivacySettings, SshSettings}; use crate::terminal::available_shells::{AvailableShell, AvailableShells}; +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::terminal::cli_agent_sessions::control_endpoint::CLIAgentControlEndpoint; use crate::terminal::color::List as ColorList; use crate::terminal::event_listener::ChannelEventListener; #[cfg(unix)] @@ -125,6 +128,12 @@ pub struct TerminalManager { /// The manager is responsible for managing the lifetime of the remote server controller. remote_server_controller: ModelHandle, + /// The manager is responsible for managing the lifetime of the pane's CLI agent control + /// endpoint. None until the shell starter is known, or when the endpoint is disabled, + /// unsupported for this shell, or failed to bind. + #[allow(dead_code)] + cli_agent_control_endpoint: Option, + /// The process ID of the PTY. Purely used for integration tests. None if the PTY has not yet /// been started. #[cfg(feature = "integration_tests")] @@ -480,6 +489,7 @@ impl TerminalManager { terminal_attributes_poller: None, pty_controller, remote_server_controller, + cli_agent_control_endpoint: None, #[cfg(feature = "integration_tests")] pid: None, inactive_pty_reads_rx, @@ -580,7 +590,7 @@ impl TerminalManager { fn on_shell_determined( manager: &mut TerminalManager, startup_directory: Option, - env_vars: HashMap, + mut env_vars: HashMap, user_default_shell_unsupported_banner_model_handle: ModelHandle, shell_startup_resources: ShellStartupResources, shell_starter_source: Option, @@ -681,6 +691,13 @@ fn on_shell_determined( .lock() .register_session_id(generated_session_id); + manager.cli_agent_control_endpoint = start_cli_agent_control_endpoint( + manager.view.id(), + &shell_starter, + &mut env_vars, + ctx, + ); + // Enqueue the init shell script (for shells that need it), then create // the PTY and start its corresponding event loop. let ShellStartupResources { @@ -763,6 +780,46 @@ fn on_shell_determined( } } +/// Binds the pane's CLI agent control endpoint, advertises it in the shell's environment, and +/// mirrors the pane's rich input state onto it. Returns `None`, leaving the environment untouched, +/// when the feature is disabled, the endpoint is unsupported for this shell, or it failed to bind. +fn start_cli_agent_control_endpoint( + terminal_view_id: EntityId, + shell_starter: &ShellStarter, + env_vars: &mut HashMap, + ctx: &mut ModelContext>, +) -> Option { + if !(FeatureFlag::HOANotifications.is_enabled() + && FeatureFlag::CLIAgentRichInput.is_enabled()) + { + return None; + } + match shell_starter { + ShellStarter::Direct(_) | ShellStarter::MSYS2(_) => {} + // Processes inside WSL or a sandbox cannot reach an endpoint bound on the host. + ShellStarter::Wsl(_) | ShellStarter::DockerSandbox(_) => return None, + } + let endpoint = match CLIAgentControlEndpoint::bind(ctx.background_executor()) { + Ok(endpoint) => endpoint, + Err(err) => { + log::warn!("Failed to create CLI agent control endpoint: {err}"); + return None; + } + }; + env_vars.insert( + WARP_CLI_AGENT_CONTROL_SOCKET_ENV.into(), + endpoint.address().to_owned(), + ); + + let publisher = endpoint.publisher(); + ctx.subscribe_to_model(&CLIAgentSessionsModel::handle(ctx), move |_, _, event, _| { + if event.terminal_view_id() == terminal_view_id { + publisher.observe(event); + } + }); + Some(endpoint) +} + impl TerminalManager { /// Sends bindkey to notify shell process to switch to PS1 logic for prompt /// with the combined prompt/command grid (we restore the saved PS1 value). diff --git a/crates/warp_core/src/cli_agent_protocol.rs b/crates/warp_core/src/cli_agent_protocol.rs index bc7b6cd42f5..5acc481d107 100644 --- a/crates/warp_core/src/cli_agent_protocol.rs +++ b/crates/warp_core/src/cli_agent_protocol.rs @@ -13,6 +13,39 @@ pub const WARP_CLI_AGENT_PROTOCOL_VERSION_ENV: &str = "WARP_CLI_AGENT_PROTOCOL_V /// Environment variable that identifies the hosting Warp client version. pub const WARP_CLIENT_VERSION_ENV: &str = "WARP_CLIENT_VERSION"; +/// Schema version emitted on the Warp-to-agent control channel. +pub const CLI_AGENT_CONTROL_PROTOCOL_VERSION: u32 = 1; + +/// Environment variable that advertises the pane-scoped Warp-to-agent control endpoint address. +pub const WARP_CLI_AGENT_CONTROL_SOCKET_ENV: &str = "WARP_CLI_AGENT_CONTROL_SOCKET"; + +/// Kinds of Warp-to-agent control events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CLIAgentControlEventKind { + RichInput, +} + +/// Wire representation of a Warp-to-agent control event, sent as one JSON line. +/// +/// Kept separate from [`CLIAgentNotification`] so the two directions can evolve independently. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CLIAgentControlEvent { + pub v: u32, + pub event: CLIAgentControlEventKind, + pub active: bool, +} + +impl CLIAgentControlEvent { + pub fn rich_input(active: bool) -> Self { + Self { + v: CLI_AGENT_CONTROL_PROTOCOL_VERSION, + event: CLIAgentControlEventKind::RichInput, + active, + } + } +} + /// Wire representation of a structured CLI-agent notification. #[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize)]