Files
galaxy/app/src/terminal/cli_agent_sessions/mod.rs
T

535 lines
19 KiB
Rust

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<String> },
}
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<String>,
pub project: Option<String>,
pub session_id: Option<String>,
pub tool_name: Option<String>,
pub tool_input_preview: Option<String>,
pub summary: Option<String>,
pub query: Option<String>,
pub response: Option<String>,
}
/// 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<String> {
self.latest_user_prompt().or_else(|| self.title_like_text())
}
pub(crate) fn latest_user_prompt(&self) -> Option<String> {
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<String> {
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<ModelHandle<CLIAgentSessionListener>>,
/// The plugin version reported by the `SessionStart` event.
/// `None` if the plugin predates version reporting or hasn't connected yet.
pub plugin_version: Option<String>,
/// `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<String>,
/// 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<String>,
/// 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<String>,
}
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<CLIAgentSessionStatus> {
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<CLIAgentSessionContext>,
},
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<EntityId, CLIAgentSession>,
/// 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<String>)>,
}
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<String>,
project: Option<String>,
session_id: Option<String>,
plugin_version: Option<String>,
remote_host: Option<String>,
should_auto_toggle_input: bool,
listener: ModelHandle<CLIAgentSessionListener>,
ctx: &mut ModelContext<Self>,
) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<String>) {
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<String> {
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<String>) -> bool {
self.plugin_auto_failures
.contains(&(agent, remote_host.clone()))
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;