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, GitReviewCommand, TerminalCommand};
4use crate::conversation::tool_calls::ToolStatus;
5use crate::conversation::{ContextUsageDisplay, MessageRole};
6use crate::screens::artifact_review::ArtifactReviewScreen;
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;
17use utils::artifact_review::ArtifactReviewElicitationMeta;
18
19impl App {
20    #[allow(clippy::too_many_lines)]
21    pub fn on_acp_event(&mut self, event: AcpEvent) {
22        match event {
23            AcpEvent::SessionUpdate(notification) => {
24                if &notification.session_id == self.session.session_id()
25                    || matches!(self.foreground, ForegroundOperation::CreatingSession { .. })
26                {
27                    self.on_session_update(&notification.update);
28                }
29            }
30            AcpEvent::ContextCleared(_) => {
31                self.reset_conversation();
32            }
33            AcpEvent::ElicitationRequest { params, responder } => {
34                let params = *params;
35                self.close_elicitation_owner();
36                if let Some(meta) = artifact_review_meta(&params) {
37                    self.open_route(Route::ArtifactReview(Box::new(ArtifactReviewScreen::new(meta, responder))));
38                    return;
39                }
40                // The settings overlay answers its own elicitations in place so
41                // an OAuth prompt does not tear down the pane that started it.
42                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
43                    overlay.on_elicitation_request(
44                        params,
45                        responder,
46                        self.browser_opener.clone(),
47                        self.clipboard_writer.clone(),
48                    );
49                    return;
50                }
51                if let Some(modal) = ElicitationModal::with_url_handlers(
52                    params,
53                    responder,
54                    self.browser_opener.clone(),
55                    self.clipboard_writer.clone(),
56                ) {
57                    self.open_overlay(Overlay::Elicitation(modal));
58                }
59            }
60            AcpEvent::McpNotification(notification) => self.on_mcp_notification(&notification),
61            AcpEvent::GitDiffEvent(params) => {
62                self.queue(Command::GitReview(GitReviewCommand::Forward(params.event)));
63            }
64            AcpEvent::AuthMethodsUpdated(params) => {
65                self.session.set_auth_methods(&params.auth_methods);
66                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
67                    overlay.update_auth_methods(&params.auth_methods);
68                }
69            }
70            AcpEvent::ConnectionClosed => self.on_connection_closed(),
71            AcpEvent::SubAgentProgress(progress) => {
72                if self.conversation.progress_indicator().accepts_activity() {
73                    self.conversation.on_sub_agent_progress(&progress);
74                }
75            }
76        }
77    }
78
79    /// Reports why a workspace move could not proceed and leaves move mode.
80    pub(super) fn abandon_workspace_move(&mut self, message: &str) {
81        self.notify(message);
82        self.foreground = ForegroundOperation::Idle;
83    }
84
85    pub(super) fn open_session_picker(&mut self, sessions: Vec<acp::SessionInfo>) {
86        let current_id = self.session.session_id().clone();
87        let others = sessions.into_iter().filter(|session| session.session_id != current_id).collect();
88        let picker = SessionPicker::new(others, self.session.capabilities().session_preview);
89        if let Some(id) = picker.initial_preview_request() {
90            self.queue(Command::Agent(AgentCommand::SessionPreview { session_id: id }));
91        }
92        self.open_overlay(Overlay::Sessions(picker));
93    }
94
95    pub(super) fn on_resumed_session(&mut self, session_id: &SessionId, response: acp::ResumeSessionResponse) {
96        match &self.foreground {
97            ForegroundOperation::ResumingSession { session_id: expected, cwd }
98            | ForegroundOperation::LoadingWorkspaceSession { session_id: expected, cwd }
99                if expected == session_id =>
100            {
101                if self.session.working_dir() != cwd {
102                    let cwd = cwd.clone();
103                    self.session.set_working_dir(cwd.clone());
104                    self.resolve_workspace(cwd);
105                }
106            }
107            _ => return,
108        }
109        self.session.update_config_options(response.config_options);
110        if matches!(self.foreground, ForegroundOperation::LoadingWorkspaceSession { .. }) {
111            self.notify(&format!("Moved to {}", self.session.working_dir().display()));
112        }
113        self.return_to_conversation();
114        self.foreground = ForegroundOperation::Idle;
115    }
116
117    pub(super) fn on_new_session(&mut self, session_id: SessionId, config_options: Vec<acp::SessionConfigOption>) {
118        if !matches!(self.foreground, ForegroundOperation::Idle | ForegroundOperation::CreatingSession { .. }) {
119            return;
120        }
121        let previous_selections = match std::mem::take(&mut self.foreground) {
122            ForegroundOperation::CreatingSession { previous_selections } => previous_selections,
123            _ => Vec::new(),
124        };
125        self.close_elicitation_owner();
126        self.return_to_conversation();
127        self.session.set_session(session_id, config_options);
128        self.restore_config_selections(&previous_selections);
129    }
130
131    /// Server notifications feed the status summary in the status line and settings overlay.
132    fn on_mcp_notification(&mut self, notification: &McpNotification) {
133        let McpNotification::ServerStatus { servers } = notification;
134        self.session.update_server_statuses(servers);
135        let servers = servers.clone();
136        if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
137            overlay.update_server_statuses(servers);
138        }
139    }
140
141    /// The connection is gone, but a remote agent may still be running.
142    /// Release local interactions and overlays, then ask the event loop to exit.
143    fn on_connection_closed(&mut self) {
144        self.close_elicitation_owner();
145        self.return_to_conversation();
146        self.foreground = ForegroundOperation::Idle;
147        self.commands.retain(|command| !matches!(command, Command::Terminal(TerminalCommand::RingBell)));
148        self.exit_state = ExitState::ConnectionLost;
149    }
150
151    /// Answers any elicitation the current route or overlay is holding, leaving the
152    /// settings overlay itself open so its pane survives.
153    fn close_elicitation_owner(&mut self) {
154        match self.overlay.as_mut() {
155            Some(Overlay::Settings(overlay)) => overlay.cancel_pending_elicitation(),
156            Some(Overlay::Elicitation(_)) => self.close_overlay(),
157            _ => {}
158        }
159    }
160
161    fn on_session_update(&mut self, update: &SessionUpdate) {
162        if matches!(update, SessionUpdate::StateUpdate(StateUpdate::Running(_))) && self.foreground.is_idle() {
163            self.foreground = ForegroundOperation::Prompt(PromptPhase::Running);
164            self.conversation.progress_indicator_mut().prompt_started();
165        }
166        if self.waiting_for_response() {
167            self.observe_activity(update);
168        }
169        match update {
170            SessionUpdate::CompactionUpdate(update) => {
171                if self.conversation.progress_indicator().accepts_activity() {
172                    self.conversation.turn_mut().apply_compaction(update);
173                }
174            }
175            SessionUpdate::StateUpdate(StateUpdate::Idle(idle)) if self.waiting_for_response() => {
176                let status = match idle.stop_reason {
177                    Some(acp::StopReason::Cancelled) => ToolStatus::Error("cancelled".to_string()),
178                    _ => ToolStatus::Success,
179                };
180                self.finish_prompt(&status);
181            }
182            SessionUpdate::UserMessage(message) => {
183                self.conversation.upsert_message(MessageRole::User, message.message_id.clone(), &message.content);
184            }
185            SessionUpdate::AgentMessage(message) => {
186                self.conversation.upsert_message(MessageRole::Assistant, message.message_id.clone(), &message.content);
187            }
188            SessionUpdate::UserMessageChunk(chunk) => {
189                self.conversation.append_message_chunk(MessageRole::User, chunk);
190            }
191            SessionUpdate::AgentMessageChunk(chunk) => {
192                self.conversation.append_message_chunk(MessageRole::Assistant, chunk);
193            }
194            SessionUpdate::ToolCallContentChunk(chunk) => {
195                self.conversation.on_tool_call_content_chunk(chunk);
196            }
197            SessionUpdate::ToolCallUpdate(update) => {
198                self.conversation.on_tool_call_update(update);
199            }
200            SessionUpdate::AvailableCommandsUpdate(update) => {
201                let agent_commands: Vec<_> = update
202                    .available_commands
203                    .iter()
204                    .map(|command| CommandEntry {
205                        name: command.name.clone(),
206                        description: command.description.clone(),
207                        has_input: command.input.is_some(),
208                        hint: match &command.input {
209                            Some(acp::AvailableCommandInput::Text(input)) => Some(input.hint.clone()),
210                            _ => None,
211                        },
212                        builtin: false,
213                    })
214                    .collect();
215                let mut all = builtin_commands(self.session.capabilities());
216                all.extend(agent_commands);
217                self.available_commands = all;
218            }
219            SessionUpdate::ConfigOptionUpdate(update) => {
220                self.session.update_config_options(update.config_options.clone());
221                if let Some(Overlay::Settings(overlay)) = self.overlay.as_mut() {
222                    overlay.update_config_options(self.session.config_options());
223                }
224            }
225            SessionUpdate::PlanUpdate(plan) => {
226                self.conversation.plan_tracker_mut().apply_update(plan, Instant::now());
227            }
228            SessionUpdate::UsageUpdate(usage) => {
229                self.conversation.turn_mut().set_context_usage(Some(ContextUsageDisplay {
230                    used_tokens: u32::try_from(usage.used).unwrap_or(u32::MAX),
231                    limit_tokens: u32::try_from(usage.size).unwrap_or(u32::MAX),
232                }));
233            }
234            _ => {}
235        }
236    }
237
238    fn observe_activity(&mut self, update: &SessionUpdate) {
239        let indicator = self.conversation.progress_indicator_mut();
240        match update {
241            SessionUpdate::AgentMessageChunk(_) | SessionUpdate::StateUpdate(StateUpdate::Running(_)) => {
242                indicator.response_started();
243            }
244            SessionUpdate::StateUpdate(StateUpdate::RequiresAction(_)) => indicator.requires_action(),
245            SessionUpdate::ToolCallUpdate(_) => indicator.tool_activity(),
246            SessionUpdate::AgentThoughtChunk(chunk) => {
247                if let acp::ContentBlock::Text(text) = &chunk.content
248                    && !text.text.is_empty()
249                {
250                    indicator.record_thought(&chunk.message_id, &text.text);
251                }
252            }
253            SessionUpdate::AgentThought(message) => match &message.content {
254                MaybeUndefined::Undefined => {}
255                MaybeUndefined::Null => indicator.replace_thought(&message.message_id, ""),
256                MaybeUndefined::Value(blocks) => {
257                    let text = acp_utils::content::map_content_blocks_to_text(blocks.clone());
258                    indicator.replace_thought(&message.message_id, &text);
259                }
260            },
261            _ => {}
262        }
263    }
264
265    pub(super) fn finish_prompt(&mut self, terminal_status: &ToolStatus) {
266        let was_in_flight = self.waiting_for_response();
267        self.foreground.finish_prompt();
268        self.conversation.turn_mut().clear_compactions();
269        self.conversation.progress_indicator_mut().prompt_finished();
270        self.conversation.finish_turn(terminal_status);
271        if was_in_flight && matches!(terminal_status, ToolStatus::Success) {
272            self.queue(Command::Terminal(TerminalCommand::RingBell));
273        }
274    }
275}
276
277pub(super) fn artifact_review_meta(params: &CreateElicitationRequest) -> Option<ArtifactReviewElicitationMeta> {
278    if !matches!(params.mode, ElicitationMode::Form(_)) {
279        return None;
280    }
281    ArtifactReviewElicitationMeta::parse(params.meta.as_ref())
282}