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