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 acp_utils::client::{AcpEvent, LoadedSession};
12use acp_utils::notifications::McpNotification;
13use agent_client_protocol::schema::v1::{self as acp, CreateElicitationRequest, ElicitationMode, SessionId};
14use std::time::Instant;
15
16impl App {
17    #[allow(clippy::too_many_lines)]
18    pub fn on_acp_event(&mut self, event: AcpEvent) {
19        match event {
20            AcpEvent::SessionUpdate { session_id, update } => {
21                if &session_id == self.session.session_id() {
22                    self.on_session_update(&update);
23                }
24            }
25            AcpEvent::PromptCompleted(stop_reason) => {
26                let status = match stop_reason {
27                    acp::StopReason::Cancelled => ToolStatus::Error("cancelled".to_string()),
28                    _ => ToolStatus::Success,
29                };
30                self.finish_prompt(&status);
31            }
32            AcpEvent::ContextCompaction(params) => {
33                if self.conversation.progress_indicator().accepts_activity() {
34                    self.conversation.turn_mut().set_compaction_active(params.active);
35                }
36            }
37            AcpEvent::ContextCleared(_) => {
38                self.reset_conversation();
39            }
40            AcpEvent::ElicitationRequest { params, responder } => {
41                let params = *params;
42                self.close_elicitation_owner();
43                if let Some(meta) = plan_review_meta(&params) {
44                    self.open_route(Route::PlanReview(Box::new(PlanReviewScreen::new(meta, responder))));
45                    return;
46                }
47                // The settings overlay answers its own elicitations in place so
48                // an OAuth prompt does not tear down the pane that started it.
49                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
50                    overlay.on_elicitation_request(
51                        params,
52                        responder,
53                        self.browser_opener.clone(),
54                        self.clipboard_writer.clone(),
55                    );
56                    return;
57                }
58                if let Some(modal) = ElicitationModal::with_url_handlers(
59                    params,
60                    responder,
61                    self.browser_opener.clone(),
62                    self.clipboard_writer.clone(),
63                ) {
64                    self.open_overlay(Overlay::Elicitation(modal));
65                }
66            }
67            AcpEvent::McpNotification(notification) => self.on_mcp_notification(&notification),
68            AcpEvent::AuthMethodsUpdated(params) => {
69                self.session.set_auth_methods(&params.auth_methods);
70                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
71                    overlay.update_auth_methods(&params.auth_methods);
72                }
73            }
74            AcpEvent::ConnectionClosed => self.on_connection_closed(),
75            AcpEvent::SessionUsage(_) => {},
76            AcpEvent::SubAgentProgress(progress) => {
77                if self.conversation.progress_indicator().accepts_activity() {
78                    self.conversation.on_sub_agent_progress(&progress);
79                }
80            }
81        }
82    }
83
84    /// Reports why a workspace move could not proceed and leaves move mode.
85    pub(super) fn abandon_workspace_move(&mut self, message: &str) {
86        self.notify(message);
87        self.session.end_workspace_move();
88    }
89
90    pub(super) fn open_session_picker(&mut self, sessions: Vec<acp::SessionInfo>) {
91        let current_id = self.session.session_id().clone();
92        let others = sessions.into_iter().filter(|session| session.session_id != current_id).collect();
93        let picker = SessionPicker::new(others, self.session.capabilities().session_preview);
94        if let Some(id) = picker.initial_preview_request() {
95            self.queue(Command::Agent(AgentCommand::SessionPreview { session_id: id }));
96        }
97        self.open_overlay(Overlay::Sessions(picker));
98    }
99
100    pub(super) fn on_loaded_session(&mut self, loaded: LoadedSession) {
101        let LoadedSession { session_id, response, replay } = loaded;
102        self.reset_turn_state();
103        for notification in replay {
104            self.on_session_update(&notification.update);
105        }
106        self.session.set_session(session_id, response.config_options.unwrap_or_default());
107        self.return_to_conversation();
108        self.session.end_workspace_move();
109    }
110
111    pub(super) fn on_new_session(&mut self, session_id: SessionId, config_options: Vec<acp::SessionConfigOption>) {
112        self.close_elicitation_owner();
113        self.return_to_conversation();
114        let previous_selections: Vec<(String, String)> = self
115            .session
116            .config_options()
117            .iter()
118            .filter_map(|option| option.select().map(|select| (option.id.clone(), select.current_value.to_string())))
119            .collect();
120        self.session.set_session(session_id, config_options);
121        self.reset_conversation();
122        self.restore_config_selections(&previous_selections);
123    }
124
125    /// Server notifications feed the status summary in the status line and settings overlay.
126    fn on_mcp_notification(&mut self, notification: &McpNotification) {
127        let McpNotification::ServerStatus { servers } = notification;
128        self.session.update_server_statuses(servers);
129        let servers = servers.clone();
130        if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
131            overlay.update_server_statuses(servers);
132        }
133    }
134
135    /// The agent is gone: answer anything it is still waiting on, tear down
136    /// every route and overlay, and ask the event loop to exit.
137    fn on_connection_closed(&mut self) {
138        self.close_elicitation_owner();
139        self.return_to_conversation();
140        self.session.end_workspace_move();
141        self.commands.retain(|command| !matches!(command, Command::Terminal(TerminalCommand::RingBell)));
142        self.exit_state = ExitState::Exiting;
143    }
144
145    /// Answers any elicitation the current route or overlay is holding, leaving the
146    /// settings overlay itself open so its pane survives.
147    fn close_elicitation_owner(&mut self) {
148        match self.overlay.as_mut() {
149            Some(Overlay::Settings(overlay)) => overlay.cancel_pending_elicitation(),
150            Some(Overlay::Elicitation(_)) => self.close_overlay(),
151            _ => {}
152        }
153    }
154
155    fn on_session_update(&mut self, update: &acp::SessionUpdate) {
156        if is_agent_activity(update) && !self.conversation.progress_indicator().accepts_activity() {
157            return;
158        }
159        match update {
160            acp::SessionUpdate::UserMessageChunk(chunk) => {
161                if let Some(text) = match &chunk.content {
162                    acp::ContentBlock::Text(text) => Some(text.text.clone()),
163                    block => placeholder_for_content_block(block).map(str::to_string),
164                } {
165                    self.conversation.append_user_content(text);
166                }
167            }
168            acp::SessionUpdate::AgentMessageChunk(chunk) => {
169                if let acp::ContentBlock::Text(text_content) = &chunk.content {
170                    if !text_content.text.is_empty() {
171                        self.conversation.progress_indicator_mut().response_started();
172                    }
173                    self.conversation.append_assistant_chunk(&text_content.text);
174                }
175            }
176            acp::SessionUpdate::AgentThoughtChunk(chunk) => {
177                if let acp::ContentBlock::Text(text_content) = &chunk.content
178                    && !text_content.text.is_empty()
179                {
180                    self.conversation.progress_indicator_mut().record_thought(&text_content.text);
181                }
182            }
183            acp::SessionUpdate::ToolCall(tool_call) => {
184                self.conversation.progress_indicator_mut().tool_activity();
185                self.conversation.on_tool_call(tool_call);
186            }
187            acp::SessionUpdate::ToolCallUpdate(update) => {
188                self.conversation.progress_indicator_mut().tool_activity();
189                self.conversation.on_tool_call_update(update);
190            }
191            acp::SessionUpdate::AvailableCommandsUpdate(update) => {
192                let agent_commands: Vec<_> = update
193                    .available_commands
194                    .iter()
195                    .map(|command| CommandEntry {
196                        name: command.name.clone(),
197                        description: command.description.clone(),
198                        has_input: command.input.is_some(),
199                        hint: match &command.input {
200                            Some(acp::AvailableCommandInput::Unstructured(input)) => Some(input.hint.clone()),
201                            _ => None,
202                        },
203                        builtin: false,
204                    })
205                    .collect();
206                let mut all = builtin_commands(self.session.capabilities());
207                all.extend(agent_commands);
208                self.available_commands = all;
209            }
210            acp::SessionUpdate::ConfigOptionUpdate(update) => {
211                self.session.update_config_options(update.config_options.clone());
212                self.conversation.finish_current_block();
213                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
214                    overlay.update_config_options(self.session.config_options());
215                }
216            }
217            acp::SessionUpdate::Plan(plan) => {
218                self.conversation.plan_tracker_mut().replace(plan.entries.clone(), Instant::now());
219                self.conversation.finish_current_block();
220            }
221            acp::SessionUpdate::UsageUpdate(usage) => {
222                self.conversation.turn_mut().set_context_usage(Some(ContextUsageDisplay {
223                    used_tokens: u32::try_from(usage.used).unwrap_or(u32::MAX),
224                    limit_tokens: u32::try_from(usage.size).unwrap_or(u32::MAX),
225                }));
226            }
227            _ => {
228                self.conversation.finish_current_block();
229            }
230        }
231    }
232
233    pub(super) fn finish_prompt(&mut self, terminal_status: &ToolStatus) {
234        let was_in_flight = self.waiting_for_response();
235        self.conversation.turn_mut().set_prompt_in_flight(false);
236        self.conversation.turn_mut().set_compaction_active(false);
237        self.conversation.progress_indicator_mut().prompt_finished();
238        self.conversation.finish_turn(terminal_status);
239        if was_in_flight && matches!(terminal_status, ToolStatus::Success) {
240            self.queue(Command::Terminal(TerminalCommand::RingBell));
241        }
242    }
243}
244
245fn is_agent_activity(update: &acp::SessionUpdate) -> bool {
246    matches!(
247        update,
248        acp::SessionUpdate::AgentMessageChunk(_)
249            | acp::SessionUpdate::AgentThoughtChunk(_)
250            | acp::SessionUpdate::ToolCall(_)
251            | acp::SessionUpdate::ToolCallUpdate(_)
252    )
253}
254
255pub(super) fn plan_review_meta(
256    params: &CreateElicitationRequest,
257) -> Option<utils::plan_review::PlanReviewElicitationMeta> {
258    if !matches!(params.mode, ElicitationMode::Form(_)) {
259        return None;
260    }
261    utils::plan_review::PlanReviewElicitationMeta::parse(params.meta.as_ref())
262}