pub mod event; pub mod listener; #[cfg(not(target_family = "wasm"))] pub(crate) mod plugin_manager; use std::collections::{HashMap, HashSet}; use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use crate::ai::blocklist::InputConfig; use self::listener::CLIAgentSessionListener; use super::CLIAgent; use event::{CLIAgentEvent, CLIAgentEventType}; /// Status of a tracked CLI agent session. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CLIAgentSessionStatus { InProgress, Success, Blocked { message: Option }, } impl CLIAgentSessionStatus { pub fn to_conversation_status(&self) -> crate::ai::agent::conversation::ConversationStatus { use crate::ai::agent::conversation::ConversationStatus; match self { CLIAgentSessionStatus::InProgress => ConversationStatus::InProgress, CLIAgentSessionStatus::Success => ConversationStatus::Success, CLIAgentSessionStatus::Blocked { message } => ConversationStatus::Blocked { blocked_action: message.clone().unwrap_or_default(), }, } } } /// Rich context accumulated from CLI agent session events. #[derive(Debug, Clone, Default)] pub struct CLIAgentSessionContext { pub cwd: Option, pub project: Option, pub session_id: Option, pub tool_name: Option, pub tool_input_preview: Option, pub summary: Option, pub query: Option, pub response: Option, } /// State of the rich input editor for composing a prompt to send to a CLI agent. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CLIAgentInputState { /// The rich input editor is not open. Closed, /// The rich input editor is open. Open { /// How this session was opened (for telemetry). entrypoint: CLIAgentInputEntrypoint, /// The input config that was active before opening rich input. previous_input_config: InputConfig, /// Whether the previous lock state was established while the input buffer was empty. previous_was_lock_set_with_empty_buffer: bool, }, } /// Why the CLI agent rich input was closed (for telemetry). #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum CLIAgentRichInputCloseReason { /// User explicitly closed (Escape, Ctrl-G, footer button). Manual, /// Auto-closed due to agent status change (e.g. Blocked). AutoToggle, /// Auto-dismissed after submitting a prompt. Submit, /// Closed for another reason (chip removed, session ended, shared session sync). Other, } /// How a [`CLIAgentInputState`] was opened. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum CLIAgentInputEntrypoint { /// User pressed Ctrl-G while a CLI agent was active. CtrlG, /// User clicked the rich input button in the CLI agent footer. FooterButton, /// Automatically opened when the CLI agent resumed work (left a blocked state) /// and the auto-show setting is enabled. AutoShow, /// Rich input was opened to mirror a shared-session participant's state. SharedSessionSync, } impl CLIAgentSessionContext { pub(crate) fn display_title(&self) -> Option { self.latest_user_prompt().or_else(|| self.title_like_text()) } pub(crate) fn latest_user_prompt(&self) -> Option { self.query .as_deref() .map(str::trim) .filter(|query| !query.is_empty()) .map(str::to_owned) } /// Returns summary text suitable as a fallback title when no user prompt is available. pub(crate) fn title_like_text(&self) -> Option { self.summary .as_deref() .map(str::trim) .filter(|summary| !summary.is_empty()) .map(str::to_owned) } } /// A tracked CLI agent session. #[derive(Debug, Clone)] pub struct CLIAgentSession { pub agent: CLIAgent, pub status: CLIAgentSessionStatus, pub session_context: CLIAgentSessionContext, /// Rich input editor state. 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. /// 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. pub plugin_version: Option, /// `None` when the session is local. /// `Some("user@hostname")` when running over SSH (warpified or legacy). /// Used as a key for per-host plugin install failure tracking. pub remote_host: Option, /// Draft text saved from the rich input composer when it was closed. /// Restored into the editor when the composer is reopened. pub draft_text: Option, /// When the session was detected via a custom toolbar command pattern, /// 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, } impl CLIAgentSession { pub fn is_remote(&self) -> bool { self.remote_host.is_some() } /// 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 { self.session_context.cwd = event.cwd.clone().or(self.session_context.cwd.take()); self.session_context.project = event .project .clone() .or(self.session_context.project.take()); self.session_context.session_id = event .session_id .clone() .or(self.session_context.session_id.take()); let new_status = match &event.event { CLIAgentEventType::PromptSubmit => { self.session_context.query = event.payload.query.clone(); self.session_context.response = None; CLIAgentSessionStatus::InProgress } CLIAgentEventType::ToolComplete => { if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) { return None; } CLIAgentSessionStatus::InProgress } CLIAgentEventType::Stop => { self.session_context.query = event.payload.query.clone(); self.session_context.response = event.payload.response.clone(); CLIAgentSessionStatus::Success } CLIAgentEventType::PermissionRequest => { self.session_context.summary = event.payload.summary.clone(); self.session_context.tool_name = event.payload.tool_name.clone(); self.session_context.tool_input_preview = event.payload.tool_input_preview.clone(); CLIAgentSessionStatus::Blocked { message: event.payload.summary.clone(), } } CLIAgentEventType::QuestionAsked => CLIAgentSessionStatus::Blocked { message: event .payload .summary .clone() .or_else(|| Some("Waiting for your answer".to_owned())), }, CLIAgentEventType::PermissionReplied => { if !matches!(self.status, CLIAgentSessionStatus::Blocked { .. }) { return None; } CLIAgentSessionStatus::InProgress } // IdlePrompt means the agent is sitting at its prompt waiting for input. // 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, }; self.status = new_status.clone(); Some(new_status) } } /// Events emitted by `CLIAgentSessionsModel` for subscribers (e.g., `AgentNotificationsModel`). #[allow(dead_code)] // `agent` fields on Started/InputSessionChanged/Ended are used for logging and future subscribers. #[derive(Debug, Clone)] pub enum CLIAgentSessionsModelEvent { Started { terminal_view_id: EntityId, agent: CLIAgent, }, StatusChanged { terminal_view_id: EntityId, agent: CLIAgent, status: CLIAgentSessionStatus, session_context: Box, }, InputSessionChanged { terminal_view_id: EntityId, agent: CLIAgent, /// The input state BEFORE this change. When transitioning from /// `Open` → `Closed`, contains the saved input config to restore. previous_input_state: CLIAgentInputState, /// The input state AFTER this change. new_input_state: CLIAgentInputState, }, Ended { terminal_view_id: EntityId, agent: CLIAgent, }, /// The agent session has been updated. Subscribers may use this as a trigger for best-effort /// saving of state derived from the agent's session. SessionUpdated { terminal_view_id: EntityId, agent: CLIAgent, }, } impl CLIAgentSessionsModelEvent { pub fn terminal_view_id(&self) -> EntityId { match self { CLIAgentSessionsModelEvent::Started { terminal_view_id, .. } | CLIAgentSessionsModelEvent::StatusChanged { terminal_view_id, .. } | CLIAgentSessionsModelEvent::InputSessionChanged { terminal_view_id, .. } | CLIAgentSessionsModelEvent::Ended { terminal_view_id, .. } | CLIAgentSessionsModelEvent::SessionUpdated { terminal_view_id, .. } => *terminal_view_id, } } } /// Singleton model that tracks pane-scoped CLI agent state and plugin-enriched session context. pub struct CLIAgentSessionsModel { sessions: HashMap, /// Tracks (agent, remote_host) pairs where an auto plugin operation (install or update) has failed. /// Shared across all views so failure in one tab is reflected everywhere. plugin_auto_failures: HashSet<(CLIAgent, Option)>, } impl Entity for CLIAgentSessionsModel { type Event = CLIAgentSessionsModelEvent; } impl SingletonEntity for CLIAgentSessionsModel {} impl CLIAgentSessionsModel { pub fn new() -> Self { Self { sessions: HashMap::new(), plugin_auto_failures: HashSet::new(), } } pub fn session(&self, terminal_view_id: EntityId) -> Option<&CLIAgentSession> { self.sessions.get(&terminal_view_id) } /// Returns `true` if the rich input editor is currently open for this terminal. pub fn is_input_open(&self, terminal_view_id: EntityId) -> bool { self.sessions .get(&terminal_view_id) .is_some_and(|s| matches!(s.input_state, CLIAgentInputState::Open { .. })) } /// Registers a plugin-backed listener on the session for this terminal. /// /// If a session for the same agent already exists (e.g. created earlier by /// command detection), it is upgraded with the listener and plugin context. /// Otherwise a new session is created. /// /// The optional `cwd` / `project` / `session_id` fields supply initial /// context when available (e.g. from a `SessionStart` event). Passing /// `None` for all three is fine — happens when the plugin is installed /// mid-session and there is no start event to extract context from. #[allow(clippy::too_many_arguments)] pub fn register_listener( &mut self, terminal_view_id: EntityId, agent: CLIAgent, cwd: Option, project: Option, session_id: Option, plugin_version: Option, remote_host: Option, should_auto_toggle_input: bool, listener: ModelHandle, ctx: &mut ModelContext, ) { if let Some(session) = self .sessions .get_mut(&terminal_view_id) .filter(|s| s.agent == agent) { // Upgrade existing session with plugin context. session.status = CLIAgentSessionStatus::InProgress; session.listener = Some(listener); session.plugin_version = plugin_version; session.remote_host = remote_host; session.should_auto_toggle_input = should_auto_toggle_input; session.session_context.cwd = cwd.or(session.session_context.cwd.take()); session.session_context.project = project.or(session.session_context.project.take()); session.session_context.session_id = session_id.or(session.session_context.session_id.take()); return; } self.set_session( terminal_view_id, CLIAgentSession { agent, status: CLIAgentSessionStatus::InProgress, session_context: CLIAgentSessionContext { cwd, project, session_id, ..Default::default() }, input_state: CLIAgentInputState::Closed, should_auto_toggle_input, listener: Some(listener), plugin_version, remote_host, draft_text: None, custom_command_prefix: None, }, ctx, ); } pub fn remove_session(&mut self, terminal_view_id: EntityId, ctx: &mut ModelContext) { if let Some(session) = self.sessions.remove(&terminal_view_id) { ctx.emit(CLIAgentSessionsModelEvent::Ended { terminal_view_id, agent: session.agent, }); } } /// Updates the session's status and context from a parsed CLI agent event. pub fn update_from_event( &mut self, terminal_view_id: EntityId, event: &CLIAgentEvent, ctx: &mut ModelContext, ) { let Some(session) = self.sessions.get_mut(&terminal_view_id) else { return; }; let event_type = &event.event; if let Some(new_status) = session.apply_event(event) { let agent = session.agent; ctx.emit(CLIAgentSessionsModelEvent::StatusChanged { terminal_view_id, agent, status: new_status, session_context: Box::new(session.session_context.clone()), }); } if matches!( event_type, CLIAgentEventType::SessionStart | CLIAgentEventType::PromptSubmit | CLIAgentEventType::ToolComplete ) { ctx.emit(CLIAgentSessionsModelEvent::SessionUpdated { terminal_view_id, agent: session.agent, }); } } pub fn open_input( &mut self, terminal_view_id: EntityId, entrypoint: CLIAgentInputEntrypoint, previous_input_config: InputConfig, previous_was_lock_set_with_empty_buffer: bool, should_auto_toggle_input: bool, ctx: &mut ModelContext, ) { let Some(session) = self.sessions.get_mut(&terminal_view_id) else { return; }; let previous_input_state = session.input_state; session.input_state = CLIAgentInputState::Open { entrypoint, previous_input_config, previous_was_lock_set_with_empty_buffer, }; session.should_auto_toggle_input = should_auto_toggle_input; ctx.emit(CLIAgentSessionsModelEvent::InputSessionChanged { terminal_view_id, agent: session.agent, previous_input_state, new_input_state: session.input_state, }); } pub fn close_input( &mut self, terminal_view_id: EntityId, should_auto_toggle_input: bool, ctx: &mut ModelContext, ) { let Some(session) = self.sessions.get_mut(&terminal_view_id) else { return; }; if session.input_state == CLIAgentInputState::Closed { return; } let previous_input_state = session.input_state; session.input_state = CLIAgentInputState::Closed; session.should_auto_toggle_input = should_auto_toggle_input; ctx.emit(CLIAgentSessionsModelEvent::InputSessionChanged { terminal_view_id, agent: session.agent, previous_input_state, new_input_state: CLIAgentInputState::Closed, }); } pub fn set_session( &mut self, terminal_view_id: EntityId, session: CLIAgentSession, 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); if let Some(old) = self.sessions.insert(terminal_view_id, session) { ctx.emit(CLIAgentSessionsModelEvent::Ended { terminal_view_id, agent: old.agent, }); } ctx.emit(CLIAgentSessionsModelEvent::Started { terminal_view_id, agent, }); } /// Records that an auto plugin operation (install or update) failed for the given agent/host. /// `remote_host` is `None` for local sessions, `Some("user@hostname")` for remote. #[cfg(not(target_family = "wasm"))] pub fn record_plugin_auto_failure(&mut self, agent: CLIAgent, remote_host: Option) { self.plugin_auto_failures.insert((agent, remote_host)); } /// Saves draft text from the rich input composer for the given terminal. /// Stores `None` for empty or whitespace-only text. pub fn set_draft(&mut self, terminal_view_id: EntityId, text: String) { if let Some(session) = self.sessions.get_mut(&terminal_view_id) { session.draft_text = if text.trim().is_empty() { None } else { Some(text) }; } } /// Clears any saved draft text for the given terminal. pub fn clear_draft(&mut self, terminal_view_id: EntityId) { if let Some(session) = self.sessions.get_mut(&terminal_view_id) { session.draft_text = None; } } /// Returns and clears the draft text for the given terminal, if any. pub fn take_draft(&mut self, terminal_view_id: EntityId) -> Option { self.sessions .get_mut(&terminal_view_id) .and_then(|s| s.draft_text.take()) } /// Whether an auto plugin operation has previously failed for this agent on this host. pub fn has_plugin_auto_failed(&self, agent: CLIAgent, remote_host: &Option) -> bool { self.plugin_auto_failures .contains(&(agent, remote_host.clone())) } } #[cfg(test)] #[path = "mod_tests.rs"] mod tests;