Skip to main content

wisp/app/
acp_reducer.rs

1use super::session::builtin_commands;
2use super::{App, ExitState, ForegroundOperation, Overlay, PromptPhase, Route};
3use crate::command::{AgentCommand, Command, TerminalCommand};
4use crate::conversation::tool_calls::ToolStatus;
5use crate::conversation::{ContextUsageDisplay, MessageRole};
6use crate::screens::plan_review::PlanReviewScreen;
7use crate::surfaces::modal::ElicitationModal;
8use crate::surfaces::picker::CommandEntry;
9use crate::surfaces::session_picker::SessionPicker;
10use acp_utils::client::AcpEvent;
11use acp_utils::notifications::McpNotification;
12use agent_client_protocol::schema::MaybeUndefined;
13use agent_client_protocol::schema::v2::{
14    self as acp, CreateElicitationRequest, ElicitationMode, SessionId, SessionUpdate, StateUpdate,
15};
16use std::time::Instant;
17
18impl App {
19    #[allow(clippy::too_many_lines)]
20    pub fn on_acp_event(&mut self, event: AcpEvent) {
21        match event {
22            AcpEvent::SessionUpdate(notification) => {
23                if &notification.session_id == self.session.session_id()
24                    || matches!(self.foreground, ForegroundOperation::CreatingSession { .. }) {
25                    self.on_session_update(&notification.update);
26                }
27            }
28            AcpEvent::ContextCleared(_) => {
29                self.reset_conversation();
30            }
31            AcpEvent::ElicitationRequest { params, responder } => {
32                let params = *params;
33                self.close_elicitation_owner();
34                if let Some(meta) = plan_review_meta(&params) {
35                    self.open_route(Route::PlanReview(Box::new(PlanReviewScreen::new(meta, responder))));
36                    return;
37                }
38                // The settings overlay answers its own elicitations in place so
39                // an OAuth prompt does not tear down the pane that started it.
40                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
41                    overlay.on_elicitation_request(
42                        params,
43                        responder,
44                        self.browser_opener.clone(),
45                        self.clipboard_writer.clone(),
46                    );
47                    return;
48                }
49                if let Some(modal) = ElicitationModal::with_url_handlers(
50                    params,
51                    responder,
52                    self.browser_opener.clone(),
53                    self.clipboard_writer.clone(),
54                ) {
55                    self.open_overlay(Overlay::Elicitation(modal));
56                }
57            }
58            AcpEvent::McpNotification(notification) => self.on_mcp_notification(&notification),
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::SubAgentProgress(progress) => {
67                if self.conversation.progress_indicator().accepts_activity() {
68                    self.conversation.on_sub_agent_progress(&progress);
69                }
70            }
71        }
72    }
73
74    /// Reports why a workspace move could not proceed and leaves move mode.
75    pub(super) fn abandon_workspace_move(&mut self, message: &str) {
76        self.notify(message);
77        self.foreground = ForegroundOperation::Idle;
78    }
79
80    pub(super) fn open_session_picker(&mut self, sessions: Vec<acp::SessionInfo>) {
81        let current_id = self.session.session_id().clone();
82        let others = sessions.into_iter().filter(|session| session.session_id != current_id).collect();
83        let picker = SessionPicker::new(others, self.session.capabilities().session_preview);
84        if let Some(id) = picker.initial_preview_request() {
85            self.queue(Command::Agent(AgentCommand::SessionPreview { session_id: id }));
86        }
87        self.open_overlay(Overlay::Sessions(picker));
88    }
89
90    pub(super) fn on_resumed_session(&mut self, session_id: &SessionId, response: acp::ResumeSessionResponse) {
91        match &self.foreground {
92            ForegroundOperation::ResumingSession { session_id: expected, cwd }
93            | ForegroundOperation::LoadingWorkspaceSession { session_id: expected, cwd } if expected == session_id => {
94                if self.session.working_dir() != cwd {
95                    let cwd = cwd.clone();
96                    self.session.set_working_dir(cwd.clone());
97                    self.resolve_workspace(cwd);
98                }
99            }
100            _ => return,
101        }
102        self.session.update_config_options(response.config_options);
103        if matches!(self.foreground, ForegroundOperation::LoadingWorkspaceSession { .. }) {
104            self.notify(&format!("Moved to {}", self.workspace_display_path(self.session.working_dir())));
105        }
106        self.return_to_conversation();
107        self.foreground = ForegroundOperation::Idle;
108    }
109
110    pub(super) fn on_new_session(&mut self, session_id: SessionId, config_options: Vec<acp::SessionConfigOption>) {
111        if !matches!(self.foreground, ForegroundOperation::Idle | ForegroundOperation::CreatingSession { .. }) {
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    fn on_session_update(&mut self, update: &SessionUpdate) {
155        if matches!(update, SessionUpdate::StateUpdate(StateUpdate::Running(_))) && self.foreground.is_idle() {
156            self.foreground = ForegroundOperation::Prompt(PromptPhase::Running);
157            self.conversation.progress_indicator_mut().prompt_started();
158        }
159        if self.waiting_for_response() {
160            self.observe_activity(update);
161        }
162        match update {
163            SessionUpdate::CompactionUpdate(update) => {
164                if self.conversation.progress_indicator().accepts_activity() {
165                    self.conversation.turn_mut().apply_compaction(update);
166                }
167            }
168            SessionUpdate::StateUpdate(StateUpdate::Idle(idle)) if self.waiting_for_response() => {
169                let status = match idle.stop_reason {
170                    Some(acp::StopReason::Cancelled) => ToolStatus::Error("cancelled".to_string()),
171                    _ => ToolStatus::Success,
172                };
173                self.finish_prompt(&status);
174            }
175            SessionUpdate::UserMessage(message) => {
176                self.conversation.upsert_message(MessageRole::User, message.message_id.clone(), &message.content);
177            }
178            SessionUpdate::AgentMessage(message) => {
179                self.conversation.upsert_message(MessageRole::Assistant, message.message_id.clone(), &message.content);
180            }
181            SessionUpdate::UserMessageChunk(chunk) => {
182                self.conversation.append_message_chunk(MessageRole::User, chunk);
183            }
184            SessionUpdate::AgentMessageChunk(chunk) => {
185                self.conversation.append_message_chunk(MessageRole::Assistant, chunk);
186            }
187            SessionUpdate::ToolCallContentChunk(chunk) => {
188                self.conversation.on_tool_call_content_chunk(chunk);
189            }
190            SessionUpdate::ToolCallUpdate(update) => {
191                self.conversation.on_tool_call_update(update);
192            }
193            SessionUpdate::AvailableCommandsUpdate(update) => {
194                let agent_commands: Vec<_> = update
195                    .available_commands
196                    .iter()
197                    .map(|command| CommandEntry {
198                        name: command.name.clone(),
199                        description: command.description.clone(),
200                        has_input: command.input.is_some(),
201                        hint: match &command.input {
202                            Some(acp::AvailableCommandInput::Text(input)) => Some(input.hint.clone()),
203                            _ => None,
204                        },
205                        builtin: false,
206                    })
207                    .collect();
208                let mut all = builtin_commands(self.session.capabilities());
209                all.extend(agent_commands);
210                self.available_commands = all;
211            }
212            SessionUpdate::ConfigOptionUpdate(update) => {
213                self.session.update_config_options(update.config_options.clone());
214                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
215                    overlay.update_config_options(self.session.config_options());
216                }
217            }
218            SessionUpdate::PlanUpdate(plan) => {
219                self.conversation.plan_tracker_mut().apply_update(plan, Instant::now());
220            }
221            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        }
229    }
230
231    fn observe_activity(&mut self, update: &SessionUpdate) {
232        let indicator = self.conversation.progress_indicator_mut();
233        match update {
234            SessionUpdate::AgentMessageChunk(_) | SessionUpdate::StateUpdate(StateUpdate::Running(_)) => {
235                indicator.response_started();
236            }
237            SessionUpdate::StateUpdate(StateUpdate::RequiresAction(_)) => indicator.requires_action(),
238            SessionUpdate::ToolCallUpdate(_) => indicator.tool_activity(),
239            SessionUpdate::AgentThoughtChunk(chunk) => {
240                if let acp::ContentBlock::Text(text) = &chunk.content
241                    && !text.text.is_empty()
242                {
243                    indicator.record_thought(&chunk.message_id, &text.text);
244                }
245            }
246            SessionUpdate::AgentThought(message) => match &message.content {
247                MaybeUndefined::Undefined => {}
248                MaybeUndefined::Null => indicator.replace_thought(&message.message_id, ""),
249                MaybeUndefined::Value(blocks) => {
250                    let text = acp_utils::content::map_content_blocks_to_text(blocks.clone());
251                    indicator.replace_thought(&message.message_id, &text);
252                }
253            },
254            _ => {}
255        }
256    }
257
258    pub(super) fn finish_prompt(&mut self, terminal_status: &ToolStatus) {
259        let was_in_flight = self.waiting_for_response();
260        self.foreground.finish_prompt();
261        self.conversation.turn_mut().clear_compactions();
262        self.conversation.progress_indicator_mut().prompt_finished();
263        self.conversation.finish_turn(terminal_status);
264        if was_in_flight && matches!(terminal_status, ToolStatus::Success) {
265            self.queue(Command::Terminal(TerminalCommand::RingBell));
266        }
267    }
268}
269
270pub(super) fn plan_review_meta(
271    params: &CreateElicitationRequest,
272) -> Option<utils::plan_review::PlanReviewElicitationMeta> {
273    if !matches!(params.mode, ElicitationMode::Form(_)) {
274        return None;
275    }
276    utils::plan_review::PlanReviewElicitationMeta::parse(params.meta.as_ref())
277}