Skip to main content

wisp/app/
acp_reducer.rs

1use super::session::builtin_commands;
2use super::{App, ExitState, ForegroundOperation, Overlay, Route};
3use crate::command::{AgentCommand, Command, GitReviewCommand, TerminalCommand};
4use crate::screens::artifact_review::ArtifactReviewScreen;
5use crate::surfaces::modal::ElicitationModal;
6use crate::surfaces::picker::CommandEntry;
7use crate::surfaces::session_picker::SessionPicker;
8use acp_utils::client::AcpEvent;
9use acp_utils::conversation::TurnFinished;
10use acp_utils::notifications::McpNotification;
11use agent_client_protocol::schema::v2::{self as acp, CreateElicitationRequest, ElicitationMode, SessionId, SessionUpdate};
12use utils::artifact_review::ArtifactReviewElicitationMeta;
13
14impl App {
15    #[allow(clippy::too_many_lines)]
16    pub fn on_acp_event(&mut self, event: AcpEvent) {
17        let current = self.is_current(&event);
18        if current
19            && let Some(TurnFinished { stop_reason }) = self.conversation.apply_event(&event)
20            && stop_reason != Some(acp::StopReason::Cancelled)
21            && self.queued_prompts.is_empty()
22        {
23            self.queue(Command::Terminal(TerminalCommand::RingBell));
24        }
25        match event {
26            AcpEvent::SessionUpdate(notification) if current => self.on_session_update(&notification.update),
27            AcpEvent::ContextCleared(_) if current => self.foreground.drop_prepared_prompt(),
28            AcpEvent::ElicitationRequest { params, responder } => {
29                let params = *params;
30                self.close_elicitation_owner();
31                if let Some(meta) = artifact_review_meta(&params) {
32                    self.open_route(Route::ArtifactReview(Box::new(ArtifactReviewScreen::new(meta, responder))));
33                    return;
34                }
35                // The settings overlay answers its own elicitations in place so
36                // an OAuth prompt does not tear down the pane that started it.
37                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
38                    overlay.on_elicitation_request(
39                        params,
40                        responder,
41                        self.browser_opener.clone(),
42                        self.clipboard_writer.clone(),
43                    );
44                    return;
45                }
46                if let Some(modal) = ElicitationModal::with_url_handlers(
47                    params,
48                    responder,
49                    self.browser_opener.clone(),
50                    self.clipboard_writer.clone(),
51                ) {
52                    self.open_overlay(Overlay::Elicitation(modal));
53                }
54            }
55            AcpEvent::McpNotification(notification) => self.on_mcp_notification(&notification),
56            AcpEvent::GitDiffEvent(params) => {
57                self.queue(Command::GitReview(GitReviewCommand::Forward(params.event)));
58            }
59            AcpEvent::AuthMethodsUpdated(params) => {
60                self.session.set_auth_methods(&params.auth_methods);
61                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
62                    overlay.update_auth_methods(&params.auth_methods);
63                }
64            }
65            AcpEvent::ConnectionClosed => self.on_connection_closed(),
66            AcpEvent::SessionUpdate(_) | AcpEvent::ContextCleared(_) | AcpEvent::SubAgentProgress(_) => {}
67        }
68    }
69
70    /// Reports why a workspace move could not proceed and leaves move mode.
71    pub(super) fn abandon_workspace_move(&mut self, message: &str) {
72        self.notify(message);
73        self.foreground = ForegroundOperation::Idle;
74    }
75
76    pub(super) fn open_session_picker(&mut self, sessions: Vec<acp::SessionInfo>) {
77        let current_id = self.session.session_id().clone();
78        let others = sessions.into_iter().filter(|session| session.session_id != current_id).collect();
79        let picker = SessionPicker::new(others, self.session.capabilities().session_preview);
80        if let Some(id) = picker.initial_preview_request() {
81            self.queue(Command::Agent(AgentCommand::SessionPreview { session_id: id }));
82        }
83        self.open_overlay(Overlay::Sessions(picker));
84    }
85
86    pub(super) fn on_resumed_session(&mut self, session_id: &SessionId, response: acp::ResumeSessionResponse) {
87        match &self.foreground {
88            ForegroundOperation::ResumingSession { session_id: expected, cwd }
89            | ForegroundOperation::LoadingWorkspaceSession { session_id: expected, cwd }
90                if expected == session_id =>
91            {
92                if self.session.working_dir() != cwd {
93                    let cwd = cwd.clone();
94                    self.session.set_working_dir(cwd.clone());
95                    self.resolve_workspace(cwd);
96                }
97            }
98            _ => return,
99        }
100        self.session.update_config_options(response.config_options);
101        if matches!(self.foreground, ForegroundOperation::LoadingWorkspaceSession { .. }) {
102            self.notify(&format!("Moved to {}", self.session.working_dir().display()));
103        }
104        self.return_to_conversation();
105        self.foreground = ForegroundOperation::Idle;
106    }
107
108    pub(super) fn on_new_session(&mut self, session_id: SessionId, config_options: Vec<acp::SessionConfigOption>) {
109        if !matches!(self.foreground, ForegroundOperation::CreatingSession { .. })
110            && !self.can_start_foreground_operation()
111        {
112            return;
113        }
114        let previous_selections = match std::mem::take(&mut self.foreground) {
115            ForegroundOperation::CreatingSession { previous_selections } => previous_selections,
116            _ => Vec::new(),
117        };
118        self.close_elicitation_owner();
119        self.return_to_conversation();
120        self.session.set_session(session_id, config_options);
121        self.restore_config_selections(&previous_selections);
122    }
123
124    /// Server notifications feed the status summary in the status line and settings overlay.
125    fn on_mcp_notification(&mut self, notification: &McpNotification) {
126        let McpNotification::ServerStatus { servers } = notification;
127        self.session.update_server_statuses(servers);
128        let servers = servers.clone();
129        if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
130            overlay.update_server_statuses(servers);
131        }
132    }
133
134    /// The connection is gone, but a remote agent may still be running.
135    /// Release local interactions and overlays, then ask the event loop to exit.
136    fn on_connection_closed(&mut self) {
137        self.close_elicitation_owner();
138        self.return_to_conversation();
139        self.foreground = ForegroundOperation::Idle;
140        self.commands.retain(|command| !matches!(command, Command::Terminal(TerminalCommand::RingBell)));
141        self.exit_state = ExitState::ConnectionLost;
142    }
143
144    /// Answers any elicitation the current route or overlay is holding, leaving the
145    /// settings overlay itself open so its pane survives.
146    fn close_elicitation_owner(&mut self) {
147        match self.overlay.as_mut() {
148            Some(Overlay::Settings(overlay)) => overlay.cancel_pending_elicitation(),
149            Some(Overlay::Elicitation(_)) => self.close_overlay(),
150            _ => {}
151        }
152    }
153
154    /// Whether an event concerns the session on screen. A session being created has no id yet, so every
155    /// event is taken to be for it.
156    fn is_current(&self, event: &AcpEvent) -> bool {
157        event.session_id().is_none_or(|session_id| session_id == self.session.session_id())
158            || matches!(self.foreground, ForegroundOperation::CreatingSession { .. })
159    }
160
161    fn on_session_update(&mut self, update: &SessionUpdate) {
162        match update {
163            SessionUpdate::AvailableCommandsUpdate(update) => {
164                let agent_commands: Vec<_> = update
165                    .available_commands
166                    .iter()
167                    .map(|command| CommandEntry {
168                        name: command.name.clone(),
169                        description: command.description.clone(),
170                        has_input: command.input.is_some(),
171                        hint: match &command.input {
172                            Some(acp::AvailableCommandInput::Text(input)) => Some(input.hint.clone()),
173                            _ => None,
174                        },
175                        builtin: false,
176                    })
177                    .collect();
178                let mut all = builtin_commands(self.session.capabilities());
179                all.extend(agent_commands);
180                self.available_commands = all;
181            }
182            SessionUpdate::ConfigOptionUpdate(update) => {
183                self.session.update_config_options(update.config_options.clone());
184                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
185                    overlay.update_config_options(self.session.config_options());
186                }
187            }
188            _ => {}
189        }
190    }
191}
192
193pub(super) fn artifact_review_meta(params: &CreateElicitationRequest) -> Option<ArtifactReviewElicitationMeta> {
194    if !matches!(params.mode, ElicitationMode::Form(_)) {
195        return None;
196    }
197    ArtifactReviewElicitationMeta::parse(params.meta.as_ref())
198}