Skip to main content

wisp/app/
acp_reducer.rs

1use super::session::builtin_commands;
2use super::{App, ExitState, Overlay, Route};
3use crate::attachment::placeholder_for_content_block;
4use crate::command::{AgentCommand, Command, TerminalCommand};
5use crate::conversation::ContextUsageDisplay;
6use crate::conversation::tool_calls::ToolStatus;
7use crate::screens::plan_review::PlanReviewScreen;
8use crate::surfaces::modal::ElicitationModal;
9use crate::surfaces::picker::CommandEntry;
10use crate::surfaces::session_picker::SessionPicker;
11use crate::surfaces::workspace_picker::WorkspacePicker;
12use acp_utils::client::AcpEvent;
13use acp_utils::notifications::McpNotification;
14use agent_client_protocol::schema::v1::{self as acp, SessionId};
15use std::time::Instant;
16
17impl App {
18    #[allow(clippy::too_many_lines)]
19    pub fn on_acp_event(&mut self, event: AcpEvent) {
20        match event {
21            AcpEvent::SessionUpdate { session_id, update } => {
22                // An update that is neither buffered for a pending load nor
23                // addressed to the session on screen belongs to one the user
24                // has already moved on from.
25                if let Some(passthrough) = self.session.buffer_update(&session_id, *update)
26                    && &session_id == self.session.session_id()
27                {
28                    self.on_session_update(&passthrough);
29                }
30            }
31            AcpEvent::PromptDone(stop_reason) => {
32                let status = match stop_reason {
33                    acp::StopReason::Cancelled => ToolStatus::Error("cancelled".to_string()),
34                    _ => ToolStatus::Success,
35                };
36                self.finish_prompt(&status);
37            }
38            AcpEvent::PromptError(error) => {
39                tracing::error!("Prompt error: {error}");
40                self.session.clear_loads();
41                self.session.abandon_workspace_load();
42                self.finish_prompt(&ToolStatus::Error(format!("failed: {error}")));
43                self.notify(&format!("Prompt failed: {error}"));
44            }
45            AcpEvent::ContextUsage(params) => {
46                self.conversation.turn_mut().set_context_usage(
47                    params.usage.context_limit.map(|limit| ContextUsageDisplay {
48                        used_tokens: params.usage.input_tokens,
49                        limit_tokens: limit,
50                    }),
51                );
52            }
53            AcpEvent::ContextCompaction(params) => {
54                self.conversation.turn_mut().set_compaction_active(params.active);
55            }
56            AcpEvent::ContextCleared(_) => {
57                self.reset_conversation();
58            }
59            AcpEvent::ElicitationRequest { params, responder } => {
60                self.close_elicitation_owner();
61                if let Some(meta) = plan_review_meta(&params) {
62                    self.open_route(Route::PlanReview(Box::new(PlanReviewScreen::new(meta, responder))));
63                    return;
64                }
65                // The settings overlay answers its own elicitations in place so
66                // an OAuth prompt does not tear down the pane that started it.
67                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
68                    overlay.on_elicitation_request(
69                        params,
70                        responder,
71                        self.browser_opener.clone(),
72                        self.clipboard_writer.clone(),
73                    );
74                    return;
75                }
76                self.open_overlay(Overlay::Elicitation(ElicitationModal::with_url_handlers(
77                    params,
78                    responder,
79                    self.browser_opener.clone(),
80                    self.clipboard_writer.clone(),
81                )));
82            }
83            AcpEvent::McpNotification(notification) => self.on_mcp_notification(&notification),
84            AcpEvent::AuthMethodsUpdated(params) => {
85                self.session.set_auth_methods(&params.auth_methods);
86                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
87                    overlay.update_auth_methods(&params.auth_methods);
88                }
89            }
90            AcpEvent::AuthenticateComplete { method_id } => {
91                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
92                    overlay.on_authenticate_complete(&method_id);
93                }
94            }
95            AcpEvent::AuthenticateFailed { method_id, error } => {
96                tracing::warn!("Provider authentication failed for {method_id}: {error}");
97                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
98                    overlay.on_authenticate_failed(&method_id);
99                }
100            }
101            AcpEvent::ConnectionClosed => self.on_connection_closed(),
102            AcpEvent::ConfigOptionUpdateFailed { error } => {
103                tracing::warn!("set_session_config_option failed: {error}");
104                self.notify(&format!("Failed to update setting: {error}"));
105            }
106            AcpEvent::SessionsListed { sessions } => self.open_session_picker(sessions),
107            AcpEvent::SessionLoaded { session_id, config_options } => {
108                self.on_session_loaded(session_id, config_options);
109            }
110            AcpEvent::NewSessionCreated { session_id, config_options } => {
111                self.on_new_session(session_id, config_options);
112            }
113            AcpEvent::SessionPreviewLoaded(preview) => {
114                if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
115                    picker.on_preview_loaded(preview);
116                }
117            }
118            AcpEvent::SessionPreviewFailed { session_id, error } => {
119                if let Some(Overlay::Sessions(picker)) = self.overlay.as_mut() {
120                    picker.on_preview_failed(&session_id, error);
121                }
122            }
123            AcpEvent::PromptSearchResults(response) => {
124                self.composer.prompt_search_on_results(response);
125            }
126            AcpEvent::PromptSearchFailed { query, error } => {
127                if let Some(picker) = self.composer.prompt_search_mut() {
128                    picker.on_failed(&query, error);
129                }
130            }
131            AcpEvent::WorkspacesListed(response) => {
132                self.open_overlay(Overlay::Workspaces(WorkspacePicker::new(response.workspaces)));
133                self.session.begin_workspace_picking();
134            }
135            AcpEvent::WorkspaceMoved(response) => self.on_workspace_moved(response.new_cwd),
136            AcpEvent::WorkspaceListFailed { error } => {
137                self.abandon_workspace_move(&format!("Failed to list workspaces: {error}"));
138            }
139            AcpEvent::WorkspaceMoveFailed { error } => {
140                self.abandon_workspace_move(&format!("Workspace move failed: {error}"));
141            }
142            AcpEvent::SubAgentProgress(progress) => {
143                self.conversation.on_sub_agent_progress(&progress);
144            }
145        }
146    }
147
148    /// Reports why a workspace move could not proceed and leaves move mode.
149    fn abandon_workspace_move(&mut self, message: &str) {
150        self.notify(message);
151        self.session.end_workspace_move();
152    }
153
154    fn open_session_picker(&mut self, sessions: Vec<acp::SessionInfo>) {
155        let current_id = self.session.session_id().clone();
156        let others = sessions.into_iter().filter(|session| session.session_id != current_id).collect();
157        let picker = SessionPicker::new(others, self.session.capabilities().session_preview);
158        if let Some(id) = picker.initial_preview_request() {
159            self.queue(Command::Agent(AgentCommand::SessionPreview { session_id: id }));
160        }
161        self.open_overlay(Overlay::Sessions(picker));
162    }
163
164    /// A requested session has arrived: replay the updates that were buffered
165    /// while it loaded. The conversation was cleared when the load was requested,
166    /// so only per-turn state is reset here.
167    fn on_session_loaded(&mut self, session_id: SessionId, config_options: Vec<acp::SessionConfigOption>) {
168        let updates = self.session.take_buffered_updates(&session_id);
169        self.session.set_session(session_id, config_options);
170        self.reset_turn_state();
171        for update in updates {
172            self.on_session_update(&update);
173        }
174        self.return_to_conversation();
175        self.session.end_workspace_move();
176    }
177
178    fn on_new_session(&mut self, session_id: SessionId, config_options: Vec<acp::SessionConfigOption>) {
179        self.session.clear_loads();
180        self.close_elicitation_owner();
181        self.return_to_conversation();
182        let previous_selections: Vec<(String, String)> = self
183            .session
184            .config_options()
185            .iter()
186            .filter_map(|option| option.select().map(|select| (option.id.clone(), select.current_value.to_string())))
187            .collect();
188        self.session.set_session(session_id, config_options);
189        self.reset_conversation();
190        self.restore_config_selections(&previous_selections);
191    }
192
193    /// Server notifications feed the status summary in the status line and settings overlay.
194    fn on_mcp_notification(&mut self, notification: &McpNotification) {
195        let McpNotification::ServerStatus { servers } = notification;
196        self.session.update_server_statuses(servers);
197        let servers = servers.clone();
198        if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
199            overlay.update_server_statuses(servers);
200        }
201    }
202
203    /// The agent is gone: answer anything it is still waiting on, tear down
204    /// every route and overlay, and ask the event loop to exit.
205    fn on_connection_closed(&mut self) {
206        self.close_elicitation_owner();
207        self.return_to_conversation();
208        self.session.end_workspace_move();
209        self.session.clear_loads();
210        self.commands.retain(|command| !matches!(command, Command::Terminal(TerminalCommand::RingBell)));
211        self.exit_state = ExitState::Exiting;
212    }
213
214    /// Answers any elicitation the current route or overlay is holding, leaving the
215    /// settings overlay itself open so its pane survives.
216    fn close_elicitation_owner(&mut self) {
217        match self.overlay.as_mut() {
218            Some(Overlay::Settings(overlay)) => overlay.cancel_pending_elicitation(),
219            Some(Overlay::Elicitation(_)) => self.close_overlay(),
220            _ => {}
221        }
222    }
223
224    fn on_session_update(&mut self, update: &acp::SessionUpdate) {
225        match update {
226            acp::SessionUpdate::UserMessageChunk(chunk) => {
227                if let Some(text) = match &chunk.content {
228                    acp::ContentBlock::Text(text) => Some(text.text.clone()),
229                    block => placeholder_for_content_block(block).map(str::to_string),
230                } {
231                    self.conversation.append_user_content(text);
232                }
233            }
234            acp::SessionUpdate::AgentMessageChunk(chunk) => {
235                if let acp::ContentBlock::Text(text_content) = &chunk.content {
236                    if !text_content.text.is_empty() {
237                        self.conversation.progress_indicator_mut().response_started();
238                    }
239                    self.conversation.append_assistant_chunk(&text_content.text);
240                }
241            }
242            acp::SessionUpdate::AgentThoughtChunk(chunk) => {
243                if let acp::ContentBlock::Text(text_content) = &chunk.content
244                    && !text_content.text.is_empty()
245                {
246                    self.conversation.progress_indicator_mut().record_thought(&text_content.text);
247                }
248            }
249            acp::SessionUpdate::ToolCall(tool_call) => {
250                self.conversation.progress_indicator_mut().tool_activity();
251                self.conversation.on_tool_call(tool_call);
252            }
253            acp::SessionUpdate::ToolCallUpdate(update) => {
254                self.conversation.progress_indicator_mut().tool_activity();
255                self.conversation.on_tool_call_update(update);
256            }
257            acp::SessionUpdate::AvailableCommandsUpdate(update) => {
258                let agent_commands: Vec<_> = update
259                    .available_commands
260                    .iter()
261                    .map(|command| CommandEntry {
262                        name: command.name.clone(),
263                        description: command.description.clone(),
264                        has_input: command.input.is_some(),
265                        hint: match &command.input {
266                            Some(acp::AvailableCommandInput::Unstructured(input)) => Some(input.hint.clone()),
267                            _ => None,
268                        },
269                        builtin: false,
270                    })
271                    .collect();
272                let mut all = builtin_commands(self.session.capabilities());
273                all.extend(agent_commands);
274                self.available_commands = all;
275            }
276            acp::SessionUpdate::ConfigOptionUpdate(update) => {
277                self.session.update_config_options(update.config_options.clone());
278                self.conversation.finish_current_block();
279                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
280                    overlay.update_config_options(self.session.config_options());
281                }
282            }
283            acp::SessionUpdate::Plan(plan) => {
284                self.conversation.plan_tracker_mut().replace(plan.entries.clone(), Instant::now());
285                self.conversation.finish_current_block();
286            }
287            _ => {
288                self.conversation.finish_current_block();
289            }
290        }
291    }
292
293    fn finish_prompt(&mut self, terminal_status: &ToolStatus) {
294        let was_in_flight = self.waiting_for_response();
295        self.conversation.turn_mut().set_prompt_in_flight(false);
296        self.conversation.turn_mut().set_compaction_active(false);
297        self.conversation.progress_indicator_mut().prompt_finished();
298        self.conversation.finish_turn(terminal_status);
299        if was_in_flight && matches!(terminal_status, ToolStatus::Success) {
300            self.queue(Command::Terminal(TerminalCommand::RingBell));
301        }
302    }
303}
304
305pub(super) fn plan_review_meta(
306    params: &acp_utils::notifications::ElicitationParams,
307) -> Option<utils::plan_review::PlanReviewElicitationMeta> {
308    match &params.request {
309        acp_utils::notifications::ElicitRequestParams::FormElicitationParams { meta, .. } => {
310            utils::plan_review::PlanReviewElicitationMeta::parse(meta.as_ref().map(|meta| &**meta).map(|meta| &**meta))
311        }
312        _ => None,
313    }
314}