Skip to main content

wisp/components/app/
mod.rs

1pub mod attachments;
2pub mod git_diff_mode;
3mod plan_review_mode;
4mod screen_router;
5mod view;
6
7use crate::session_loading_buffer::SessionLoadingBuffer;
8use crate::settings::cycle_quick_option;
9use crate::settings::cycle_reasoning_option;
10use crate::settings::resolve_content_padding;
11use crate::settings::resolve_status_line_settings;
12use agent_client_protocol::schema::SessionConfigKind;
13use agent_client_protocol::schema::SessionUpdate;
14pub use git_diff_mode::{GitDiffLoadState, GitDiffMode, GitDiffViewMessage};
15pub use plan_review_mode::{PlanReviewAction, PlanReviewInput, PlanReviewMode};
16use screen_router::ScreenRouter;
17use screen_router::ScreenRouterMessage;
18
19use crate::components::conversation_screen::ConversationScreen;
20use crate::components::conversation_screen::ConversationScreenMessage;
21use crate::components::plan_review::PlanDocument;
22use crate::components::status_line::ContextUsageDisplay;
23use crate::keybindings::Keybindings;
24use crate::settings;
25use crate::settings::overlay::{SettingsMessage, SettingsOverlay};
26use crate::settings::{ResolvedStatusLineSettings, WispSettings};
27use crate::workspace_status::WorkspaceStatus;
28use acp_utils::client::{AcpEvent, AcpPromptHandle};
29use acp_utils::config_meta::SelectOptionMeta;
30use acp_utils::config_option_id::ConfigOptionId;
31use acp_utils::notifications::{
32    AetherCapabilities, CreateElicitationRequestParams, ElicitationAction, ElicitationResponse,
33};
34use agent_client_protocol::Responder;
35use agent_client_protocol::schema::{self as acp, SessionId};
36use attachments::build_attachment_blocks;
37use std::path::{Path, PathBuf};
38use std::time::{Duration, Instant};
39use tui::RendererCommand;
40use tui::{Component, Event, Frame, KeyEvent, ViewContext};
41use utils::plan_review::{PlanReviewDecision, PlanReviewElicitationMeta};
42
43#[derive(Debug, Clone)]
44pub struct PromptAttachment {
45    pub path: PathBuf,
46    pub display_name: String,
47}
48
49/// Result of processing a single ACP event.
50pub enum EventOutcome {
51    Render { commands: Vec<RendererCommand> },
52    DontRender,
53}
54
55impl EventOutcome {
56    pub fn render() -> Self {
57        Self::Render { commands: Vec::new() }
58    }
59
60    pub fn dont_render() -> Self {
61        Self::DontRender
62    }
63}
64
65pub struct AppInfo {
66    pub session_id: SessionId,
67    pub agent_name: String,
68    pub prompt_capabilities: acp::PromptCapabilities,
69    pub session_capabilities: acp::SessionCapabilities,
70    pub config_options: Vec<acp::SessionConfigOption>,
71    pub auth_methods: Vec<acp::AuthMethod>,
72    pub working_dir: PathBuf,
73    pub workspace_status: WorkspaceStatus,
74    pub prompt_handle: AcpPromptHandle,
75    pub settings: WispSettings,
76}
77
78#[doc = include_str!("../../docs/app.md")]
79pub struct App {
80    agent_name: String,
81    context_usage: Option<ContextUsageDisplay>,
82    exit_requested: bool,
83    ctrl_c_pressed_at: Option<Instant>,
84    conversation_screen: ConversationScreen,
85    prompt_capabilities: acp::PromptCapabilities,
86    config_options: Vec<acp::SessionConfigOption>,
87    server_statuses: Vec<acp_utils::notifications::McpServerStatusEntry>,
88    auth_methods: Vec<acp::AuthMethod>,
89    settings_overlay: Option<SettingsOverlay>,
90    screen_router: ScreenRouter,
91    pending_plan_review_response: Option<Responder<ElicitationResponse>>,
92    keybindings: Keybindings,
93    session_id: SessionId,
94    session_loading_buffer: SessionLoadingBuffer,
95    prompt_handle: AcpPromptHandle,
96    working_dir: PathBuf,
97    workspace_status: WorkspaceStatus,
98    content_padding: usize,
99    status_line_settings: ResolvedStatusLineSettings,
100}
101
102impl App {
103    pub fn new(info: AppInfo) -> Self {
104        let AppInfo {
105            session_id,
106            agent_name,
107            prompt_capabilities,
108            session_capabilities,
109            config_options,
110            auth_methods,
111            working_dir,
112            workspace_status,
113            prompt_handle,
114            settings,
115        } = info;
116        let keybindings = Keybindings::default();
117        let content_padding = resolve_content_padding(&settings);
118        let status_line_settings = resolve_status_line_settings(&settings);
119        let capabilities = AetherCapabilities::from_meta(session_capabilities.meta.as_ref());
120        Self {
121            agent_name,
122            context_usage: None,
123            exit_requested: false,
124            ctrl_c_pressed_at: None,
125            conversation_screen: ConversationScreen::new(
126                keybindings.clone(),
127                content_padding,
128                working_dir.clone(),
129                capabilities,
130            ),
131            prompt_capabilities,
132            config_options,
133            server_statuses: Vec::new(),
134            auth_methods,
135            settings_overlay: None,
136            screen_router: ScreenRouter::new(working_dir.clone()),
137            pending_plan_review_response: None,
138            keybindings,
139            session_id,
140            session_loading_buffer: SessionLoadingBuffer::new(),
141            prompt_handle,
142            working_dir,
143            workspace_status,
144            content_padding,
145            status_line_settings,
146        }
147    }
148
149    pub fn exit_requested(&self) -> bool {
150        self.exit_requested
151    }
152
153    pub fn exit_confirmation_active(&self) -> bool {
154        self.ctrl_c_pressed_at.is_some()
155    }
156
157    pub fn has_settings_overlay(&self) -> bool {
158        self.settings_overlay.is_some()
159    }
160
161    pub fn needs_mouse_capture(&self) -> bool {
162        self.settings_overlay.as_ref().is_some_and(SettingsOverlay::needs_mouse_capture)
163            || self.screen_router.is_full_screen_mode()
164    }
165
166    pub fn wants_tick(&self) -> bool {
167        self.conversation_screen.wants_tick() || self.ctrl_c_pressed_at.is_some()
168    }
169
170    fn git_diff_mode_mut(&mut self) -> &mut GitDiffMode {
171        self.screen_router.git_diff_mode_mut()
172    }
173
174    pub fn on_acp_event(&mut self, event: AcpEvent) -> EventOutcome {
175        let mut commands = Vec::new();
176        match event {
177            AcpEvent::SessionUpdate { session_id, update } => {
178                return self.on_acp_session_update(&session_id, *update);
179            }
180            AcpEvent::ContextCleared(_) => {
181                self.conversation_screen.reset_after_context_cleared();
182                self.context_usage = None;
183            }
184            AcpEvent::ContextCompaction(params) => {
185                self.conversation_screen.set_compaction_active(params.active);
186            }
187            AcpEvent::ContextUsage(params) => {
188                self.context_usage = params
189                    .usage
190                    .context_limit
191                    .filter(|limit| *limit > 0)
192                    .map(|limit| ContextUsageDisplay::new(params.usage.input_tokens, limit));
193            }
194            AcpEvent::SubAgentProgress(progress) => self.conversation_screen.on_sub_agent_progress(&progress),
195            AcpEvent::AuthMethodsUpdated(params) => self.update_auth_methods(params.auth_methods),
196            AcpEvent::McpNotification(notification) => self.on_mcp_notification(notification),
197            AcpEvent::PromptDone(stop_reason) => self.on_prompt_done(stop_reason, &mut commands),
198            AcpEvent::PromptError(error) => {
199                self.session_loading_buffer.clear();
200                self.conversation_screen.on_prompt_error(&error);
201            }
202            AcpEvent::ElicitationRequest { params, responder } => self.on_elicitation_request(params, responder),
203            AcpEvent::AuthenticateComplete { method_id } => self.on_authenticate_complete(&method_id),
204            AcpEvent::AuthenticateFailed { method_id, error } => self.on_authenticate_failed(&method_id, &error),
205            AcpEvent::ConfigOptionUpdateFailed { error } => {
206                tracing::warn!("set_session_config_option failed: {error}");
207                self.conversation_screen
208                    .conversation
209                    .push_user_message(&format!("[wisp] Failed to update setting: {error}"));
210            }
211            AcpEvent::SessionsListed { sessions } => {
212                let current_id = &self.session_id;
213                let filtered: Vec<_> = sessions.into_iter().filter(|s| s.session_id != *current_id).collect();
214                let messages = self.conversation_screen.open_session_picker(filtered);
215                self.handle_conversation_messages_sync(messages);
216            }
217            // SessionLoaded intentionally does NOT restore previous config selections:
218            // when the user loads an existing session, the server's stored config for
219            // that session is authoritative.
220            AcpEvent::SessionLoaded { session_id, config_options } => {
221                let replay_updates = self.session_loading_buffer.take(&session_id);
222                self.session_id = session_id;
223                self.conversation_screen.on_workspace_move_finished();
224                for update in replay_updates {
225                    self.on_session_update(&update);
226                }
227                self.update_config_options(&config_options);
228            }
229            AcpEvent::NewSessionCreated { session_id, config_options } => {
230                self.session_loading_buffer.clear();
231                let previous_selections = current_config_selections(&self.config_options);
232                self.session_id = session_id;
233                self.update_config_options(&config_options);
234                self.context_usage = None;
235                self.restore_config_selections(&previous_selections);
236            }
237            AcpEvent::ConnectionClosed => {
238                self.session_loading_buffer.clear();
239                self.exit_requested = true;
240            }
241            AcpEvent::PromptSearchResults(response) => {
242                self.conversation_screen.on_prompt_search_results(response);
243            }
244            AcpEvent::PromptSearchFailed { query, error } => {
245                self.conversation_screen.on_prompt_search_failed(&query, error);
246            }
247            AcpEvent::SessionPreviewLoaded(preview) => {
248                self.conversation_screen.on_session_preview_loaded(preview);
249            }
250            AcpEvent::SessionPreviewFailed { session_id, error } => {
251                self.conversation_screen.on_session_preview_failed(&session_id, error);
252            }
253            AcpEvent::WorkspacesListed(response) => {
254                self.conversation_screen.open_workspace_picker(response.workspaces);
255            }
256            AcpEvent::WorkspaceListFailed { error } => {
257                self.conversation_screen.on_workspace_list_failed(&error);
258            }
259            AcpEvent::WorkspaceMoved(response) => {
260                self.on_workspace_moved(&response.new_cwd, &mut commands);
261            }
262            AcpEvent::WorkspaceMoveFailed { error } => {
263                self.conversation_screen.on_workspace_move_failed(&error);
264            }
265        }
266        EventOutcome::Render { commands }
267    }
268
269    fn on_workspace_moved(&mut self, new_cwd: &Path, commands: &mut Vec<RendererCommand>) {
270        self.working_dir = new_cwd.to_path_buf();
271        self.conversation_screen.set_working_dir(new_cwd.to_path_buf());
272        self.workspace_status = WorkspaceStatus::resolve(new_cwd);
273        self.screen_router.set_git_diff_working_dir(new_cwd.to_path_buf());
274
275        self.conversation_screen.reset_after_context_cleared();
276        commands.push(RendererCommand::ClearScreen);
277        let session_id = self.session_id.clone();
278        if self.start_session_load(&session_id, new_cwd) {
279            self.conversation_screen.on_workspace_session_loading();
280        } else {
281            self.conversation_screen.on_workspace_move_finished();
282        }
283    }
284
285    fn start_session_load(&mut self, session_id: &SessionId, cwd: &Path) -> bool {
286        self.session_loading_buffer.begin_load(session_id.clone());
287        if let Err(e) = self.prompt_handle.load_session(session_id, cwd) {
288            self.session_loading_buffer.remove(session_id);
289            tracing::warn!("Failed to load session: {e}");
290            return false;
291        }
292        true
293    }
294
295    async fn handle_key(&mut self, commands: &mut Vec<RendererCommand>, key_event: KeyEvent) {
296        if self.keybindings.exit.matches(key_event) {
297            if self.ctrl_c_pressed_at.is_some() {
298                self.exit_requested = true;
299            } else {
300                self.conversation_screen.clear_prompt_composer();
301                self.ctrl_c_pressed_at = Some(Instant::now());
302            }
303            return;
304        }
305
306        if self.keybindings.toggle_git_diff.matches(key_event) && !self.conversation_screen.has_modal() {
307            if let Some(msg) = self.screen_router.toggle_git_diff() {
308                self.handle_screen_router_message(commands, msg).await;
309            }
310            return;
311        }
312
313        let event = Event::Key(key_event);
314
315        if self.screen_router.is_full_screen_mode() {
316            for msg in self.screen_router.on_event(&event).await.unwrap_or_default() {
317                self.handle_screen_router_message(commands, msg).await;
318            }
319        } else if self.settings_overlay.is_some() {
320            self.handle_settings_overlay_event(commands, &event).await;
321        } else {
322            let outcome = self.conversation_screen.on_event(&event).await;
323            let consumed = outcome.is_some();
324            self.handle_conversation_messages(commands, outcome).await;
325            if !consumed {
326                self.handle_fallthrough_keybindings(key_event);
327            }
328        }
329    }
330
331    async fn submit_prompt(&mut self, user_input: String, attachments: Vec<PromptAttachment>) {
332        let outcome = build_attachment_blocks(&attachments).await;
333        self.conversation_screen.conversation.push_user_message("");
334        self.conversation_screen.conversation.push_user_message(&user_input);
335        for placeholder in &outcome.transcript_placeholders {
336            self.conversation_screen.conversation.push_user_message(placeholder);
337        }
338        for w in outcome.warnings {
339            self.conversation_screen.conversation.push_user_message(&format!("[wisp] {w}"));
340        }
341
342        if let Some(message) = self.media_support_error(&outcome.blocks) {
343            self.conversation_screen.reject_local_prompt(&message);
344            return;
345        }
346
347        let _ = self.prompt_handle.prompt(
348            &self.session_id,
349            &user_input,
350            if outcome.blocks.is_empty() { None } else { Some(outcome.blocks) },
351        );
352    }
353
354    async fn handle_conversation_messages(
355        &mut self,
356        commands: &mut Vec<RendererCommand>,
357        outcome: Option<Vec<ConversationScreenMessage>>,
358    ) {
359        for msg in outcome.unwrap_or_default() {
360            match msg {
361                ConversationScreenMessage::SendPrompt { user_input, attachments } => {
362                    self.conversation_screen.on_prompt_sent();
363                    self.submit_prompt(user_input, attachments).await;
364                }
365                ConversationScreenMessage::ClearScreen => {
366                    commands.push(RendererCommand::ClearScreen);
367                }
368                ConversationScreenMessage::NewSession => {
369                    commands.push(RendererCommand::ClearScreen);
370                    let _ = self.prompt_handle.new_session(&self.working_dir);
371                }
372                ConversationScreenMessage::OpenSettings => {
373                    self.open_settings_overlay();
374                }
375                ConversationScreenMessage::OpenSessionPicker => {
376                    let _ = self.prompt_handle.list_sessions();
377                }
378                ConversationScreenMessage::OpenWorkspacePicker => {
379                    if let Err(e) = self.prompt_handle.list_workspaces(&self.session_id) {
380                        self.conversation_screen.on_workspace_list_failed(&e.to_string());
381                        tracing::warn!("Failed to request workspace list: {e}");
382                    }
383                }
384                ConversationScreenMessage::MoveWorkspace { target } => {
385                    self.conversation_screen.on_workspace_move_started();
386                    if let Err(e) = self.prompt_handle.move_workspace(&self.session_id, target) {
387                        self.conversation_screen.on_workspace_move_failed(&e.to_string());
388                        tracing::warn!("Failed to request workspace move: {e}");
389                    }
390                }
391                ConversationScreenMessage::LoadSession { session_id, cwd } => {
392                    self.start_session_load(&session_id, &cwd);
393                }
394                ConversationScreenMessage::SearchPrompts(params) => {
395                    if let Err(e) = self.prompt_handle.search_prompts(params) {
396                        tracing::warn!("Failed to send prompt search: {e}");
397                    }
398                }
399                ConversationScreenMessage::RequestSessionPreview { session_id } => {
400                    self.request_session_preview(&session_id);
401                }
402            }
403        }
404    }
405
406    fn handle_conversation_messages_sync(&mut self, messages: Vec<ConversationScreenMessage>) {
407        for msg in messages {
408            if let ConversationScreenMessage::RequestSessionPreview { session_id } = msg {
409                self.request_session_preview(&session_id);
410            }
411        }
412    }
413
414    fn request_session_preview(&self, session_id: &SessionId) {
415        if let Err(e) = self.prompt_handle.session_preview(session_id) {
416            tracing::warn!("Failed to send session preview request: {e}");
417        }
418    }
419
420    fn handle_fallthrough_keybindings(&mut self, key_event: KeyEvent) {
421        if self.keybindings.cycle_reasoning.matches(key_event) {
422            if let Some((id, val)) = cycle_reasoning_option(&self.config_options)
423                && self.prompt_handle.set_config_option(&self.session_id, &id, &val).is_ok()
424            {
425                self.update_config_option_value(&id, &val);
426            }
427            return;
428        }
429
430        if self.keybindings.cycle_mode.matches(key_event) {
431            if let Some((id, val)) = cycle_quick_option(&self.config_options)
432                && self.prompt_handle.set_config_option(&self.session_id, &id, &val).is_ok()
433            {
434                self.update_config_option_value(&id, &val);
435            }
436            return;
437        }
438
439        if self.keybindings.cancel.matches(key_event)
440            && self.conversation_screen.is_busy()
441            && let Err(e) = self.prompt_handle.cancel(&self.session_id)
442        {
443            tracing::warn!("Failed to send cancel: {e}");
444        }
445    }
446
447    async fn handle_settings_overlay_event(&mut self, commands: &mut Vec<RendererCommand>, event: &Event) {
448        let Some(ref mut overlay) = self.settings_overlay else {
449            return;
450        };
451        let messages = overlay.on_event(event).await.unwrap_or_default();
452
453        for msg in messages {
454            match msg {
455                SettingsMessage::Close => {
456                    self.settings_overlay = None;
457                    return;
458                }
459                SettingsMessage::SetConfigOption { config_id, value } => {
460                    let _ = self.prompt_handle.set_config_option(&self.session_id, &config_id, &value);
461                }
462                SettingsMessage::SetTheme(theme) => {
463                    commands.push(RendererCommand::SetTheme(theme));
464                }
465                SettingsMessage::AuthenticateServer(name) => {
466                    let _ = self.prompt_handle.authenticate_mcp_server(&self.session_id, &name);
467                }
468                SettingsMessage::AuthenticateProvider(ref method_id) => {
469                    if let Some(ref mut overlay) = self.settings_overlay {
470                        overlay.on_authenticate_started(method_id);
471                    }
472                    let _ = self.prompt_handle.authenticate(method_id);
473                }
474            }
475        }
476    }
477
478    fn open_settings_overlay(&mut self) {
479        self.settings_overlay =
480            Some(settings::create_overlay(&self.config_options, &self.server_statuses, &self.auth_methods));
481    }
482
483    fn update_config_options(&mut self, config_options: &[acp::SessionConfigOption]) {
484        self.config_options = config_options.to_vec();
485        if let Some(ref mut overlay) = self.settings_overlay {
486            overlay.update_config_options(config_options);
487        }
488    }
489
490    fn update_config_option_value(&mut self, config_id: &str, value: &str) {
491        let Some(option) = self.config_options.iter_mut().find(|option| option.id.0.as_ref() == config_id) else {
492            return;
493        };
494
495        let SessionConfigKind::Select(select) = &mut option.kind else {
496            return;
497        };
498
499        select.current_value = value.to_string().into();
500    }
501
502    fn update_auth_methods(&mut self, auth_methods: Vec<acp::AuthMethod>) {
503        self.auth_methods = auth_methods;
504        if let Some(ref mut overlay) = self.settings_overlay {
505            overlay.update_auth_methods(self.auth_methods.clone());
506        }
507    }
508
509    fn restore_config_selections(&self, previous: &[(String, String)]) {
510        let new_selections = current_config_selections(&self.config_options);
511        for (id, old_value) in previous {
512            let still_exists = new_selections.iter().any(|(new_id, _)| new_id == id);
513            if !still_exists {
514                tracing::debug!(config_id = id, "config option no longer present in new session");
515                continue;
516            }
517            let server_reset = new_selections.iter().any(|(new_id, new_val)| new_id == id && new_val != old_value);
518            if server_reset && let Err(e) = self.prompt_handle.set_config_option(&self.session_id, id, old_value) {
519                tracing::warn!(config_id = id, error = %e, "failed to restore config option");
520            }
521        }
522    }
523
524    async fn handle_screen_router_message(&mut self, commands: &mut Vec<RendererCommand>, msg: ScreenRouterMessage) {
525        match msg {
526            ScreenRouterMessage::LoadGitDiff | ScreenRouterMessage::RefreshGitDiff => {
527                self.git_diff_mode_mut().complete_load().await;
528            }
529            ScreenRouterMessage::SendPrompt { user_input } => {
530                if self.conversation_screen.is_waiting() {
531                    return;
532                }
533
534                self.conversation_screen.on_prompt_sent();
535                self.submit_prompt(user_input, Vec::new()).await;
536                self.screen_router.close_git_diff();
537            }
538            ScreenRouterMessage::FinishPlanReview(action) => {
539                let response = plan_review_response(action);
540                if let Some(responder) = self.pending_plan_review_response.take() {
541                    let _ = responder.respond(response);
542                }
543            }
544        }
545        let _ = commands;
546    }
547
548    fn on_acp_session_update(&mut self, session_id: &SessionId, update: SessionUpdate) -> EventOutcome {
549        let Some(update) = self.session_loading_buffer.push(session_id, update) else {
550            return EventOutcome::dont_render();
551        };
552        self.on_session_update(&update);
553        EventOutcome::render()
554    }
555
556    fn on_session_update(&mut self, update: &acp::SessionUpdate) {
557        self.conversation_screen.on_session_update(update);
558
559        if let acp::SessionUpdate::ConfigOptionUpdate(config_update) = update {
560            self.update_config_options(&config_update.config_options);
561        }
562    }
563
564    fn on_prompt_done(&mut self, stop_reason: acp::StopReason, commands: &mut Vec<RendererCommand>) {
565        let was_waiting = self.conversation_screen.is_waiting();
566        let cancelled = matches!(stop_reason, acp::StopReason::Cancelled);
567        self.conversation_screen.on_prompt_done(stop_reason);
568        if was_waiting && !cancelled {
569            commands.push(RendererCommand::Bell);
570        }
571    }
572
573    fn on_elicitation_request(
574        &mut self,
575        params: acp_utils::notifications::ElicitationParams,
576        responder: Responder<ElicitationResponse>,
577    ) {
578        if let Some(meta) = plan_review_meta_from_request(&params.request) {
579            self.settings_overlay = None;
580            if let Some(existing) = self.pending_plan_review_response.replace(responder) {
581                let _ = existing.respond(cancel_response());
582            }
583            let document = PlanDocument::parse(meta.plan_path, &meta.markdown);
584            let input = PlanReviewInput { title: meta.title, document };
585            self.screen_router.open_plan_review(input);
586            return;
587        }
588
589        if let Some(ref mut overlay) = self.settings_overlay {
590            overlay.on_elicitation_request(params, responder);
591        } else {
592            self.conversation_screen.on_elicitation_request(params, responder);
593        }
594    }
595
596    fn on_mcp_notification(&mut self, notification: acp_utils::notifications::McpNotification) {
597        use acp_utils::notifications::McpNotification;
598        match notification {
599            McpNotification::ServerStatus { servers } => {
600                if let Some(ref mut overlay) = self.settings_overlay {
601                    overlay.update_server_statuses(servers.clone());
602                }
603                self.server_statuses = servers;
604            }
605            McpNotification::UrlElicitationComplete(params) => {
606                if let Some(ref mut overlay) = self.settings_overlay {
607                    overlay.on_url_elicitation_complete(&params);
608                }
609                self.conversation_screen.on_url_elicitation_complete(&params);
610            }
611        }
612    }
613
614    fn on_authenticate_complete(&mut self, method_id: &str) {
615        if let Some(ref mut overlay) = self.settings_overlay {
616            overlay.on_authenticate_complete(method_id);
617        }
618    }
619
620    fn on_authenticate_failed(&mut self, method_id: &str, error: &str) {
621        tracing::warn!("Provider auth failed for {method_id}: {error}");
622        if let Some(ref mut overlay) = self.settings_overlay {
623            overlay.on_authenticate_failed(method_id);
624        }
625    }
626
627    fn media_support_error(&self, blocks: &[acp::ContentBlock]) -> Option<String> {
628        let requires_image = blocks.iter().any(|block| matches!(block, acp::ContentBlock::Image(_)));
629        let requires_audio = blocks.iter().any(|block| matches!(block, acp::ContentBlock::Audio(_)));
630
631        if !requires_image && !requires_audio {
632            return None;
633        }
634
635        if requires_image && !self.prompt_capabilities.image {
636            return Some("ACP agent does not support image input.".to_string());
637        }
638        if requires_audio && !self.prompt_capabilities.audio {
639            return Some("ACP agent does not support audio input.".to_string());
640        }
641
642        let option =
643            self.config_options.iter().find(|option| option.id.0.as_ref() == ConfigOptionId::Model.as_str())?;
644        let acp::SessionConfigKind::Select(select) = &option.kind else {
645            return None;
646        };
647
648        let values: Vec<_> =
649            select.current_value.0.split(',').map(str::trim).filter(|value| !value.is_empty()).collect();
650
651        if values.is_empty() {
652            return None;
653        }
654
655        let acp::SessionConfigSelectOptions::Ungrouped(options) = &select.options else {
656            return None;
657        };
658
659        let selected_meta: Vec<_> = values
660            .iter()
661            .filter_map(|value| {
662                options
663                    .iter()
664                    .find(|option| option.value.0.as_ref() == *value)
665                    .map(|option| SelectOptionMeta::from_meta(option.meta.as_ref()))
666            })
667            .collect();
668
669        if selected_meta.len() != values.len() {
670            return Some("Current model selection is missing prompt capability metadata.".into());
671        }
672
673        if requires_image && selected_meta.iter().any(|meta| !meta.supports_image) {
674            return Some("Current model selection does not support image input.".to_string());
675        }
676        if requires_audio && selected_meta.iter().any(|meta| !meta.supports_audio) {
677            return Some("Current model selection does not support audio input.".to_string());
678        }
679
680        None
681    }
682}
683
684impl Component for App {
685    type Message = RendererCommand;
686
687    async fn on_event(&mut self, event: &Event) -> Option<Vec<RendererCommand>> {
688        let mut commands = Vec::new();
689        match event {
690            Event::Key(key_event) => self.handle_key(&mut commands, *key_event).await,
691            Event::Paste(_) => {
692                self.settings_overlay = None;
693                if self.screen_router.is_full_screen_mode() {
694                    for msg in self.screen_router.on_event(event).await.unwrap_or_default() {
695                        self.handle_screen_router_message(&mut commands, msg).await;
696                    }
697                } else {
698                    let outcome = self.conversation_screen.on_event(event).await;
699                    self.handle_conversation_messages(&mut commands, outcome).await;
700                }
701            }
702            Event::Tick => {
703                if let Some(instant) = self.ctrl_c_pressed_at
704                    && instant.elapsed() > Duration::from_secs(1)
705                {
706                    self.ctrl_c_pressed_at = None;
707                }
708                let now = Instant::now();
709                self.conversation_screen.on_tick(now);
710            }
711            Event::Mouse(_) => {
712                if self.screen_router.is_full_screen_mode() {
713                    for msg in self.screen_router.on_event(event).await.unwrap_or_default() {
714                        self.handle_screen_router_message(&mut commands, msg).await;
715                    }
716                } else if self.settings_overlay.is_some() {
717                    self.handle_settings_overlay_event(&mut commands, event).await;
718                } else if self.conversation_screen.has_modal() {
719                    let outcome = self.conversation_screen.on_event(event).await;
720                    self.handle_conversation_messages(&mut commands, outcome).await;
721                }
722            }
723            Event::Resize(_) => {}
724        }
725        Some(commands)
726    }
727
728    fn render(&mut self, ctx: &ViewContext) -> Frame {
729        self.conversation_screen.refresh_caches(ctx);
730
731        let height = (ctx.size.height.saturating_sub(1)) as usize;
732        if let Some(ref mut overlay) = self.settings_overlay
733            && height >= 3
734        {
735            overlay.update_child_viewport(height.saturating_sub(4));
736        }
737
738        view::build_frame(self, ctx)
739    }
740}
741
742fn plan_review_meta_from_request(request: &CreateElicitationRequestParams) -> Option<PlanReviewElicitationMeta> {
743    match request {
744        CreateElicitationRequestParams::FormElicitationParams { meta, .. } => {
745            PlanReviewElicitationMeta::parse(meta.as_ref().map(|meta| &meta.0))
746        }
747        CreateElicitationRequestParams::UrlElicitationParams { .. } => None,
748    }
749}
750
751fn plan_review_response(action: PlanReviewAction) -> ElicitationResponse {
752    match action {
753        PlanReviewAction::Approve => ElicitationResponse {
754            action: ElicitationAction::Accept,
755            content: Some(PlanReviewDecision::Approve.response_content(None)),
756        },
757        PlanReviewAction::RequestChanges { feedback } => ElicitationResponse {
758            action: ElicitationAction::Accept,
759            content: Some(PlanReviewDecision::Deny.response_content(Some(&feedback))),
760        },
761        PlanReviewAction::Cancel => cancel_response(),
762    }
763}
764
765fn cancel_response() -> ElicitationResponse {
766    ElicitationResponse { action: ElicitationAction::Cancel, content: None }
767}
768
769fn current_config_selections(options: &[acp::SessionConfigOption]) -> Vec<(String, String)> {
770    options
771        .iter()
772        .filter_map(|opt| {
773            let acp::SessionConfigKind::Select(ref select) = opt.kind else {
774                return None;
775            };
776            Some((opt.id.0.to_string(), select.current_value.0.to_string()))
777        })
778        .collect()
779}
780
781#[cfg(test)]
782pub(crate) mod test_helpers {
783    use crate::settings::StatusLineSettings;
784
785    use super::*;
786    use acp_utils::client::PromptCommand;
787    use tokio::sync::mpsc;
788
789    pub fn test_workspace_status() -> WorkspaceStatus {
790        WorkspaceStatus::new("~/code/foo", Some("main".to_string()))
791    }
792
793    pub fn make_app() -> App {
794        make_app_with_options("test", acp::PromptCapabilities::new(), &[], vec![], AcpPromptHandle::noop())
795    }
796
797    pub fn make_app_with_config(config_options: &[acp::SessionConfigOption]) -> App {
798        make_app_with_options("test", acp::PromptCapabilities::new(), config_options, vec![], AcpPromptHandle::noop())
799    }
800
801    pub fn make_app_with_auth(auth_methods: Vec<acp::AuthMethod>) -> App {
802        make_app_with_options("test", acp::PromptCapabilities::new(), &[], auth_methods, AcpPromptHandle::noop())
803    }
804
805    pub fn make_app_with_config_recording(
806        config_options: &[acp::SessionConfigOption],
807    ) -> (App, mpsc::UnboundedReceiver<PromptCommand>) {
808        let (handle, rx) = AcpPromptHandle::recording();
809        let app = make_app_with_options("test", acp::PromptCapabilities::new(), config_options, vec![], handle);
810        (app, rx)
811    }
812
813    pub fn make_app_with_session_id(session_id: &str) -> App {
814        make_app_with_options(session_id, acp::PromptCapabilities::new(), &[], vec![], AcpPromptHandle::noop())
815    }
816
817    pub fn make_app_with_config_and_capabilities_recording(
818        config_options: &[acp::SessionConfigOption],
819        prompt_capabilities: acp::PromptCapabilities,
820    ) -> (App, mpsc::UnboundedReceiver<PromptCommand>) {
821        let (handle, rx) = AcpPromptHandle::recording();
822        let app = make_app_with_options("test", prompt_capabilities, config_options, vec![], handle);
823        (app, rx)
824    }
825
826    fn make_app_with_options(
827        session_id: &str,
828        prompt_capabilities: acp::PromptCapabilities,
829        config_options: &[acp::SessionConfigOption],
830        auth_methods: Vec<acp::AuthMethod>,
831        prompt_handle: AcpPromptHandle,
832    ) -> App {
833        App::new(AppInfo {
834            session_id: SessionId::new(session_id),
835            agent_name: "test-agent".to_string(),
836            prompt_capabilities,
837            session_capabilities: acp::SessionCapabilities::new().meta(Some(
838                AetherCapabilities { prompt_search: true, session_preview: true, workspace_move: true }.to_meta(),
839            )),
840            config_options: config_options.to_vec(),
841            auth_methods,
842            working_dir: PathBuf::from("."),
843            workspace_status: test_workspace_status(),
844            prompt_handle,
845            settings: WispSettings::default().with_default_status_line(StatusLineSettings::defaults()),
846        })
847    }
848}
849
850#[cfg(test)]
851mod tests {
852    use super::test_helpers::*;
853    use super::*;
854    use crate::components::command_picker::CommandEntry;
855    use crate::components::conversation_screen::Modal;
856    use crate::components::conversation_window::SegmentContent;
857    use crate::components::elicitation_form::ElicitationForm;
858    use crate::components::progress_indicator::ProgressActivity;
859    use crate::settings::{DEFAULT_CONTENT_PADDING, save_settings};
860    use crate::settings::{ThemeSettings, WispSettings};
861    use crate::test_helpers::{elicitation_params, modified_key, url_elicitation_params, with_wisp_home};
862    use acp_utils::ElicitationSchema;
863    use acp_utils::testing::test_connection;
864    use std::fs;
865    use std::path::Path;
866    use std::time::Duration;
867    use tempfile::TempDir;
868    use tokio::task::LocalSet;
869    use tui::testing::render_component;
870    use tui::{Frame, KeyCode, KeyModifiers, Renderer, Theme, ViewContext};
871    use utils::plan_review::PlanReviewElicitationMeta;
872
873    fn make_renderer() -> Renderer<Vec<u8>> {
874        Renderer::new(Vec::new(), Theme::default(), (80, 24))
875    }
876
877    fn render_app(renderer: &mut Renderer<Vec<u8>>, app: &mut App, context: &ViewContext) -> Frame {
878        renderer.render_frame(|ctx| app.render(ctx)).unwrap();
879        app.render(context)
880    }
881
882    fn frame_contains(output: &Frame, text: &str) -> bool {
883        output.lines().iter().any(|line| line.plain_text().contains(text))
884    }
885
886    async fn send_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
887        app.on_event(&modified_key(code, modifiers)).await;
888    }
889
890    fn setup_themes_dir(files: &[&str]) -> TempDir {
891        let temp_dir = TempDir::new().unwrap();
892        let themes_dir = temp_dir.path().join("themes");
893        fs::create_dir_all(&themes_dir).unwrap();
894        for f in files {
895            fs::write(themes_dir.join(f), "x").unwrap();
896        }
897        temp_dir
898    }
899
900    fn make_plan_entry(name: &str, status: acp::PlanEntryStatus) -> acp::PlanEntry {
901        acp::PlanEntry::new(name, acp::PlanEntryPriority::Medium, status)
902    }
903
904    fn make_plan_review_params(markdown: &str) -> acp_utils::notifications::ElicitationParams {
905        let meta = PlanReviewElicitationMeta::new(Path::new("/tmp/test-plan.md"), markdown)
906            .to_json()
907            .expect("serialize plan review metadata");
908
909        acp_utils::notifications::ElicitationParams {
910            server_name: "plan-server".to_string(),
911            request: acp_utils::notifications::CreateElicitationRequestParams::FormElicitationParams {
912                meta: Some(
913                    serde_json::from_value(serde_json::Value::Object(meta))
914                        .expect("deserialize plan review metadata into rmcp meta"),
915                ),
916                message: "Approve plan?".to_string(),
917                requested_schema: acp_utils::ElicitationSchema::builder()
918                    .required_string("decision")
919                    .optional_string("feedback")
920                    .build()
921                    .expect("build plan review requested schema"),
922            },
923        }
924    }
925
926    fn mode_model_options(
927        current_mode: impl Into<String>,
928        current_model: impl Into<String>,
929    ) -> Vec<acp::SessionConfigOption> {
930        vec![
931            acp::SessionConfigOption::select(
932                "mode",
933                "Mode",
934                current_mode.into(),
935                vec![
936                    acp::SessionConfigSelectOption::new("Planner", "Planner"),
937                    acp::SessionConfigSelectOption::new("Coder", "Coder"),
938                ],
939            )
940            .category(acp::SessionConfigOptionCategory::Mode),
941            acp::SessionConfigOption::select(
942                "model",
943                "Model",
944                current_model.into(),
945                vec![
946                    acp::SessionConfigSelectOption::new("gpt-4o", "GPT-4o"),
947                    acp::SessionConfigSelectOption::new("claude", "Claude"),
948                ],
949            )
950            .category(acp::SessionConfigOptionCategory::Model),
951        ]
952    }
953
954    fn image_model_options() -> Vec<acp::SessionConfigOption> {
955        vec![
956            acp::SessionConfigOption::select(
957                "model",
958                "Model",
959                "anthropic:claude-sonnet-4-5",
960                vec![
961                    acp::SessionConfigSelectOption::new("anthropic:claude-sonnet-4-5", "Claude Sonnet").meta(
962                        SelectOptionMeta { reasoning_levels: vec![], supports_image: true, supports_audio: false }
963                            .into_meta(),
964                    ),
965                    acp::SessionConfigSelectOption::new("deepseek:deepseek-chat", "DeepSeek").meta(
966                        SelectOptionMeta { reasoning_levels: vec![], supports_image: false, supports_audio: false }
967                            .into_meta(),
968                    ),
969                ],
970            )
971            .category(acp::SessionConfigOptionCategory::Model),
972        ]
973    }
974
975    #[test]
976    fn settings_overlay_with_themes() {
977        let temp_dir = setup_themes_dir(&["sage.tmTheme"]);
978        with_wisp_home(temp_dir.path(), || {
979            let mut app = make_app();
980            app.open_settings_overlay();
981            assert!(app.settings_overlay.is_some());
982        });
983
984        let temp_dir = setup_themes_dir(&["sage.tmTheme", "nord.tmTheme"]);
985        with_wisp_home(temp_dir.path(), || {
986            let settings =
987                WispSettings { theme: ThemeSettings { file: Some("nord.tmTheme".to_string()) }, ..Default::default() };
988            save_settings(&settings).unwrap();
989            let mut app = make_app();
990            app.open_settings_overlay();
991            assert!(app.settings_overlay.is_some());
992        });
993    }
994
995    #[test]
996    fn command_picker_cursor_stays_in_input_prompt() {
997        let mut app = make_app();
998        let mut renderer = make_renderer();
999        app.conversation_screen.prompt_composer.open_command_picker_with_entries(vec![CommandEntry {
1000            name: "settings".to_string(),
1001            description: "Open settings".to_string(),
1002            has_input: false,
1003            hint: None,
1004            builtin: true,
1005        }]);
1006
1007        let context = ViewContext::new((120, 40));
1008        let output = render_app(&mut renderer, &mut app, &context);
1009        let input_row =
1010            output.lines().iter().position(|line| line.plain_text().contains("> ")).expect("input prompt should exist");
1011        assert_eq!(output.cursor().row, input_row);
1012    }
1013
1014    #[test]
1015    fn settings_overlay_replaces_conversation_window() {
1016        let options = vec![acp::SessionConfigOption::select(
1017            "model",
1018            "Model",
1019            "m1",
1020            vec![acp::SessionConfigSelectOption::new("m1", "M1")],
1021        )];
1022        let mut app = make_app_with_config(&options);
1023        let mut renderer = make_renderer();
1024        app.open_settings_overlay();
1025
1026        let ctx = ViewContext::new((120, 40));
1027        assert!(frame_contains(&render_app(&mut renderer, &mut app, &ctx), "Configuration"));
1028        app.settings_overlay = None;
1029        assert!(!frame_contains(&render_app(&mut renderer, &mut app, &ctx), "Configuration"));
1030    }
1031
1032    #[test]
1033    fn extract_model_display_handles_comma_separated_value() {
1034        use crate::components::status_line::extract_model_display;
1035        let options = vec![acp::SessionConfigOption::select(
1036            "model",
1037            "Model",
1038            "a:x,b:y",
1039            vec![
1040                acp::SessionConfigSelectOption::new("a:x", "Alpha / X"),
1041                acp::SessionConfigSelectOption::new("b:y", "Beta / Y"),
1042                acp::SessionConfigSelectOption::new("c:z", "Gamma / Z"),
1043            ],
1044        )];
1045        assert_eq!(extract_model_display(&options).as_deref(), Some("Alpha / X + Beta / Y"));
1046    }
1047
1048    #[test]
1049    fn extract_reasoning_effort_returns_none_for_none_value() {
1050        use crate::components::status_line::extract_reasoning_effort;
1051        use acp_utils::config_option_id::ConfigOptionId;
1052        let options = vec![acp::SessionConfigOption::select(
1053            ConfigOptionId::ReasoningEffort.as_str(),
1054            "Reasoning",
1055            "none",
1056            vec![
1057                acp::SessionConfigSelectOption::new("none", "None"),
1058                acp::SessionConfigSelectOption::new("low", "Low"),
1059            ],
1060        )];
1061        assert_eq!(extract_reasoning_effort(&options), None);
1062    }
1063
1064    #[test]
1065    fn render_hides_plan_header_when_no_entries_are_visible() {
1066        let mut app = make_app();
1067        let mut renderer = make_renderer();
1068        let grace_period = app.conversation_screen.plan_tracker.grace_period;
1069        app.conversation_screen.plan_tracker.replace(
1070            vec![make_plan_entry("1", acp::PlanEntryStatus::Completed)],
1071            Instant::now().checked_sub(grace_period + Duration::from_millis(1)).unwrap(),
1072        );
1073        app.conversation_screen.plan_tracker.on_tick(Instant::now());
1074
1075        let output = render_app(&mut renderer, &mut app, &ViewContext::new((120, 40)));
1076        assert!(!frame_contains(&output, "Plan"));
1077    }
1078
1079    #[test]
1080    fn plan_version_increments_on_replace_and_clear() {
1081        let mut app = make_app();
1082        let v0 = app.conversation_screen.plan_tracker.version();
1083
1084        app.conversation_screen
1085            .plan_tracker
1086            .replace(vec![make_plan_entry("Task A", acp::PlanEntryStatus::Pending)], Instant::now());
1087        let v1 = app.conversation_screen.plan_tracker.version();
1088        assert!(v1 > v0, "replace should increment version");
1089
1090        app.conversation_screen.plan_tracker.clear();
1091        assert!(app.conversation_screen.plan_tracker.version() > v1, "clear should increment version");
1092    }
1093
1094    #[test]
1095    fn sessions_listed_filters_out_current_session() {
1096        let mut app = make_app_with_session_id("current-session");
1097        app.on_acp_event(AcpEvent::SessionsListed {
1098            sessions: vec![
1099                acp::SessionInfo::new("other-session-1", PathBuf::from("/project"))
1100                    .title("First other session".to_string()),
1101                acp::SessionInfo::new("current-session", PathBuf::from("/project"))
1102                    .title("Current session title".to_string()),
1103                acp::SessionInfo::new("other-session-2", PathBuf::from("/other"))
1104                    .title("Second other session".to_string()),
1105            ],
1106        });
1107
1108        let Some(Modal::SessionPicker(picker)) = &mut app.conversation_screen.active_modal else {
1109            panic!("expected session picker modal");
1110        };
1111        let lines = render_component(|ctx| picker.render(ctx), 60, 10).get_lines();
1112
1113        let has = |text: &str| lines.iter().any(|l| l.contains(text));
1114        assert!(!has("Current session title"), "current session should be filtered out");
1115        assert!(has("First other session"), "first other session should be present");
1116        assert!(has("Second other session"), "second other session should be present");
1117    }
1118
1119    #[tokio::test]
1120    async fn custom_exit_keybinding_triggers_exit() {
1121        use crate::keybindings::KeyBinding;
1122        let mut app = make_app();
1123        app.keybindings.exit = KeyBinding::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
1124
1125        send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await;
1126        assert!(!app.exit_requested(), "default Ctrl+C should not exit");
1127        assert!(!app.exit_confirmation_active(), "Ctrl+C should not trigger exit confirmation when rebound");
1128
1129        send_key(&mut app, KeyCode::Char('q'), KeyModifiers::CONTROL).await;
1130        assert!(!app.exit_requested(), "first Ctrl+Q should trigger confirmation, not exit");
1131        assert!(app.exit_confirmation_active(), "first Ctrl+Q should activate confirmation");
1132
1133        send_key(&mut app, KeyCode::Char('q'), KeyModifiers::CONTROL).await;
1134        assert!(app.exit_requested(), "second Ctrl+Q should exit");
1135    }
1136
1137    #[tokio::test]
1138    async fn ctrl_g_toggles_git_diff_viewer() {
1139        let mut app = make_app();
1140
1141        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1142        assert!(app.screen_router.is_git_diff(), "should open git diff");
1143
1144        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1145        assert!(!app.screen_router.is_git_diff(), "should close git diff");
1146    }
1147
1148    #[tokio::test]
1149    async fn needs_mouse_capture_in_git_diff() {
1150        let mut app = make_app();
1151        assert!(!app.needs_mouse_capture());
1152
1153        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1154        assert!(app.needs_mouse_capture());
1155
1156        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1157        assert!(!app.needs_mouse_capture());
1158    }
1159
1160    #[tokio::test(flavor = "current_thread")]
1161    async fn ctrl_g_blocked_during_elicitation() {
1162        LocalSet::new()
1163            .run_until(async {
1164                let mut app = make_app();
1165                let (cx, mut peer) = test_connection().await;
1166                let (responder, _rx) = peer.fake_elicitation(&cx).await;
1167                app.conversation_screen.active_modal = Some(Modal::Elicitation(ElicitationForm::from_params(
1168                    elicitation_params("test-server", "test", ElicitationSchema::builder().build().unwrap()),
1169                    responder,
1170                )));
1171
1172                send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1173                assert!(!app.screen_router.is_git_diff(), "git diff should not open during elicitation");
1174            })
1175            .await;
1176    }
1177
1178    #[tokio::test(flavor = "current_thread")]
1179    async fn plan_review_elicitation_opens_full_screen_review() {
1180        LocalSet::new()
1181            .run_until(async {
1182                let mut app = make_app();
1183                let (cx, mut peer) = test_connection().await;
1184                let (responder, _rx) = peer.fake_elicitation(&cx).await;
1185
1186                app.on_elicitation_request(make_plan_review_params("# Plan\n\n- item"), responder);
1187
1188                assert!(app.screen_router.is_plan_review(), "plan review mode should open");
1189                assert!(app.conversation_screen.active_modal.is_none(), "plan review should bypass modal form");
1190            })
1191            .await;
1192    }
1193
1194    #[tokio::test(flavor = "current_thread")]
1195    async fn regular_form_elicitation_still_uses_modal_form() {
1196        LocalSet::new()
1197            .run_until(async {
1198                let mut app = make_app();
1199                let (cx, mut peer) = test_connection().await;
1200                let (responder, _rx) = peer.fake_elicitation(&cx).await;
1201
1202                app.on_elicitation_request(
1203                    elicitation_params("test-server", "regular form", ElicitationSchema::builder().build().unwrap()),
1204                    responder,
1205                );
1206
1207                assert!(!app.screen_router.is_plan_review());
1208                assert!(matches!(app.conversation_screen.active_modal, Some(Modal::Elicitation(_))));
1209            })
1210            .await;
1211    }
1212
1213    #[tokio::test(flavor = "current_thread")]
1214    async fn plan_review_finish_routes_response_and_closes_mode() {
1215        LocalSet::new()
1216            .run_until(async {
1217                let mut app = make_app();
1218                let (cx, mut peer) = test_connection().await;
1219                let (responder, rx) = peer.fake_elicitation(&cx).await;
1220                app.on_elicitation_request(make_plan_review_params("# Plan"), responder);
1221
1222                send_key(&mut app, KeyCode::Char('a'), KeyModifiers::NONE).await;
1223
1224                assert!(!app.screen_router.is_plan_review(), "plan review mode should close after finish");
1225                let response = rx.await.expect("plan review response should be sent");
1226                assert_eq!(response.action, acp_utils::notifications::ElicitationAction::Accept);
1227                assert_eq!(response.content.expect("approve content")["decision"], "approve");
1228            })
1229            .await;
1230    }
1231
1232    #[tokio::test(flavor = "current_thread")]
1233    async fn plan_review_cancel_routes_cancel_response() {
1234        LocalSet::new()
1235            .run_until(async {
1236                let mut app = make_app();
1237                let (cx, mut peer) = test_connection().await;
1238                let (responder, rx) = peer.fake_elicitation(&cx).await;
1239                app.on_elicitation_request(make_plan_review_params("# Plan"), responder);
1240
1241                send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1242
1243                let response = rx.await.expect("plan review response should be sent");
1244                assert_eq!(response.action, acp_utils::notifications::ElicitationAction::Cancel);
1245                assert!(response.content.is_none());
1246            })
1247            .await;
1248    }
1249
1250    #[tokio::test(flavor = "current_thread")]
1251    async fn replacing_pending_plan_review_cancels_the_previous_response() {
1252        LocalSet::new()
1253            .run_until(async {
1254                let mut app = make_app();
1255                let (cx, mut peer) = test_connection().await;
1256                let (first_responder, first_rx) = peer.fake_elicitation(&cx).await;
1257                let (second_responder, second_rx) = peer.fake_elicitation(&cx).await;
1258
1259                app.on_elicitation_request(make_plan_review_params("# First"), first_responder);
1260                app.on_elicitation_request(make_plan_review_params("# Second"), second_responder);
1261
1262                let first_response = first_rx.await.expect("first plan review response should be sent");
1263                assert_eq!(first_response.action, acp_utils::notifications::ElicitationAction::Cancel);
1264                assert!(first_response.content.is_none());
1265                assert!(app.screen_router.is_plan_review(), "replacement plan review should stay open");
1266
1267                send_key(&mut app, KeyCode::Char('a'), KeyModifiers::NONE).await;
1268
1269                let second_response = second_rx.await.expect("replacement plan review response should be sent");
1270                assert_eq!(second_response.action, acp_utils::notifications::ElicitationAction::Accept);
1271                assert_eq!(second_response.content.expect("approve content")["decision"], "approve");
1272            })
1273            .await;
1274    }
1275
1276    #[tokio::test]
1277    async fn esc_in_diff_mode_does_not_cancel() {
1278        let mut app = make_app();
1279        app.conversation_screen.on_prompt_sent();
1280        app.screen_router.enter_git_diff_for_test();
1281
1282        send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1283
1284        assert!(!app.exit_requested());
1285        assert!(
1286            app.conversation_screen.is_waiting(),
1287            "Esc should NOT cancel a running prompt while git diff mode is active"
1288        );
1289    }
1290
1291    #[tokio::test]
1292    async fn git_diff_submit_sends_prompt_and_closes_diff_when_idle() {
1293        use acp_utils::client::PromptCommand;
1294
1295        let (mut app, mut rx) = make_app_with_config_recording(&[]);
1296        app.screen_router.enter_git_diff_for_test();
1297
1298        let mut commands = Vec::new();
1299        app.handle_screen_router_message(
1300            &mut commands,
1301            ScreenRouterMessage::SendPrompt { user_input: "Looks good".to_string() },
1302        )
1303        .await;
1304
1305        assert!(!app.screen_router.is_git_diff(), "successful submit should exit git diff mode");
1306        assert!(app.conversation_screen.is_waiting(), "submit should transition into waiting state");
1307
1308        let cmd = rx.try_recv().expect("expected Prompt command to be sent");
1309        match cmd {
1310            PromptCommand::Prompt { text, .. } => {
1311                assert!(text.contains("Looks good"));
1312            }
1313            other => panic!("expected Prompt command, got {other:?}"),
1314        }
1315    }
1316
1317    #[tokio::test]
1318    async fn git_diff_submit_while_waiting_is_ignored_and_keeps_diff_open() {
1319        let (mut app, mut rx) = make_app_with_config_recording(&[]);
1320        app.conversation_screen.on_prompt_sent();
1321        app.screen_router.enter_git_diff_for_test();
1322
1323        let mut commands = Vec::new();
1324        app.handle_screen_router_message(
1325            &mut commands,
1326            ScreenRouterMessage::SendPrompt { user_input: "Needs follow-up".to_string() },
1327        )
1328        .await;
1329
1330        assert!(app.screen_router.is_git_diff(), "blocked submit should keep git diff mode open");
1331        assert!(rx.try_recv().is_err(), "no prompt should be sent while waiting");
1332    }
1333
1334    #[tokio::test]
1335    async fn mouse_scroll_ignored_in_conversation_mode() {
1336        use tui::{MouseEvent, MouseEventKind};
1337        let mut app = make_app();
1338        let mouse = MouseEvent { kind: MouseEventKind::ScrollDown, column: 0, row: 0, modifiers: KeyModifiers::NONE };
1339        app.on_event(&Event::Mouse(mouse)).await;
1340    }
1341
1342    #[tokio::test]
1343    async fn prompt_composer_submit_pushes_echo_lines() {
1344        use crate::components::conversation_window::SegmentContent;
1345        let mut app = make_app();
1346        let mut commands = Vec::new();
1347        app.handle_conversation_messages(
1348            &mut commands,
1349            Some(vec![ConversationScreenMessage::SendPrompt { user_input: "hello".to_string(), attachments: vec![] }]),
1350        )
1351        .await;
1352
1353        let has_hello = app
1354            .conversation_screen
1355            .conversation
1356            .segments()
1357            .any(|seg| matches!(seg, SegmentContent::UserMessage(text) if text == "hello"));
1358        assert!(has_hello, "conversation buffer should contain the user input");
1359    }
1360
1361    #[tokio::test]
1362    async fn unsupported_media_is_blocked_locally() {
1363        let (mut app, mut rx) = make_app_with_config_and_capabilities_recording(
1364            &image_model_options(),
1365            acp::PromptCapabilities::new().image(true).audio(false),
1366        );
1367        let mut commands = Vec::new();
1368        let temp = tempfile::tempdir().unwrap();
1369        let audio_path = temp.path().join("clip.wav");
1370        std::fs::write(&audio_path, b"fake wav").unwrap();
1371
1372        app.handle_conversation_messages(
1373            &mut commands,
1374            Some(vec![ConversationScreenMessage::SendPrompt {
1375                user_input: "listen".to_string(),
1376                attachments: vec![PromptAttachment { path: audio_path, display_name: "clip.wav".to_string() }],
1377            }]),
1378        )
1379        .await;
1380
1381        assert!(rx.try_recv().is_err(), "prompt should be blocked locally");
1382        assert!(!app.conversation_screen.is_waiting());
1383        let messages: Vec<_> = app
1384            .conversation_screen
1385            .conversation
1386            .segments()
1387            .filter_map(|segment| match segment {
1388                SegmentContent::UserMessage(text) => Some(text.clone()),
1389                _ => None,
1390            })
1391            .collect();
1392        assert!(messages.iter().any(|text| text == "listen"));
1393        assert!(messages.iter().any(|text| text == "[audio attachment: clip.wav]"));
1394        assert!(messages.iter().any(|text| {
1395            text == "[wisp] ACP agent does not support audio input."
1396                || text == "[wisp] Current model selection does not support audio input."
1397        }));
1398    }
1399
1400    #[test]
1401    fn replayed_media_user_chunks_render_placeholders() {
1402        use crate::components::conversation_window::SegmentContent;
1403        let mut app = make_app();
1404
1405        app.on_session_update(&acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Image(
1406            acp::ImageContent::new("aW1n", "image/png"),
1407        ))));
1408        app.on_session_update(&acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Audio(
1409            acp::AudioContent::new("YXVkaW8=", "audio/wav"),
1410        ))));
1411
1412        let segments: Vec<_> = app.conversation_screen.conversation.segments().collect();
1413        assert!(matches!(
1414            segments[0],
1415            SegmentContent::UserMessage(text) if text == "[image attachment]"
1416        ));
1417        assert!(matches!(
1418            segments[1],
1419            SegmentContent::UserMessage(text) if text == "[audio attachment]"
1420        ));
1421    }
1422
1423    #[test]
1424    fn prompt_composer_open_settings() {
1425        let mut app = make_app();
1426        let mut commands = Vec::new();
1427        tokio::runtime::Runtime::new().unwrap().block_on(
1428            app.handle_conversation_messages(&mut commands, Some(vec![ConversationScreenMessage::OpenSettings])),
1429        );
1430        assert!(app.settings_overlay.is_some(), "settings overlay should be opened");
1431    }
1432
1433    #[test]
1434    fn settings_overlay_close_clears_overlay() {
1435        let mut app = make_app();
1436        app.open_settings_overlay();
1437        app.settings_overlay = None;
1438        assert!(app.settings_overlay.is_none(), "close should clear overlay");
1439    }
1440
1441    #[tokio::test]
1442    async fn tick_advances_spinner_animations() {
1443        let mut app = make_app();
1444        let tool_call = acp::ToolCall::new("tool-1".to_string(), "test_tool");
1445        app.conversation_screen.tool_call_statuses.on_tool_call(&tool_call);
1446        app.conversation_screen.progress_indicator.update(ProgressActivity { agent_busy: true, ..Default::default() });
1447
1448        let ctx = ViewContext::new((80, 24));
1449        let tool_before = app.conversation_screen.tool_call_statuses.render_tool("tool-1", &ctx);
1450        let prog_before = app.conversation_screen.progress_indicator.render(&ctx);
1451
1452        app.on_event(&Event::Tick).await;
1453
1454        let tool_after = app.conversation_screen.tool_call_statuses.render_tool("tool-1", &ctx);
1455        let prog_after = app.conversation_screen.progress_indicator.render(&ctx);
1456
1457        assert_ne!(
1458            tool_before.lines()[0].plain_text(),
1459            tool_after.lines()[0].plain_text(),
1460            "tick should advance tool spinner"
1461        );
1462        assert_ne!(
1463            prog_before.lines()[1].plain_text(),
1464            prog_after.lines()[1].plain_text(),
1465            "tick should advance progress spinner"
1466        );
1467    }
1468
1469    #[test]
1470    fn prompt_done_does_not_bell_when_not_waiting_or_cancelled() {
1471        let mut app = make_app();
1472        let outcome = app.on_acp_event(AcpEvent::PromptDone(acp::StopReason::EndTurn));
1473        match outcome {
1474            EventOutcome::Render { commands } => assert!(commands.is_empty(), "duplicate PromptDone should not bell"),
1475            EventOutcome::DontRender => panic!("prompt done should render"),
1476        }
1477
1478        let mut app = make_app();
1479        app.conversation_screen.on_prompt_sent();
1480        let outcome = app.on_acp_event(AcpEvent::PromptDone(acp::StopReason::Cancelled));
1481        match outcome {
1482            EventOutcome::Render { commands } => assert!(commands.is_empty(), "cancelled prompt should not bell"),
1483            EventOutcome::DontRender => panic!("prompt done should render"),
1484        }
1485    }
1486
1487    #[test]
1488    fn on_prompt_error_clears_waiting_state() {
1489        let mut app = make_app();
1490        app.conversation_screen.on_prompt_sent();
1491        app.conversation_screen.on_prompt_error(&acp::Error::internal_error());
1492        assert!(!app.conversation_screen.is_waiting());
1493        assert!(!app.exit_requested());
1494    }
1495
1496    #[test]
1497    fn auth_events_and_connection_close_exit_behavior() {
1498        let mut app =
1499            make_app_with_auth(vec![acp::AuthMethod::Agent(acp::AuthMethodAgent::new("anthropic", "Anthropic"))]);
1500        app.on_authenticate_complete("anthropic");
1501        assert!(!app.exit_requested(), "authenticate_complete should not exit");
1502
1503        let mut app = make_app();
1504        app.on_authenticate_failed("anthropic", "bad token");
1505        assert!(!app.exit_requested(), "authenticate_failed should not exit");
1506
1507        let mut app = make_app();
1508        app.on_acp_event(AcpEvent::ConnectionClosed);
1509        assert!(app.exit_requested(), "connection_closed should exit");
1510    }
1511
1512    #[tokio::test]
1513    async fn clear_screen_returns_clear_command() {
1514        let mut app = make_app();
1515        let mut commands = Vec::new();
1516        app.handle_conversation_messages(&mut commands, Some(vec![ConversationScreenMessage::ClearScreen])).await;
1517        assert!(
1518            commands.iter().any(|c| matches!(c, RendererCommand::ClearScreen)),
1519            "should contain ClearScreen command"
1520        );
1521    }
1522
1523    #[tokio::test]
1524    async fn cancel_sends_directly_via_prompt_handle() {
1525        let mut app = make_app();
1526        app.conversation_screen.on_prompt_sent();
1527        send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1528        assert!(!app.exit_requested());
1529    }
1530
1531    #[test]
1532    fn new_session_restores_changed_config_selections() {
1533        use acp_utils::client::PromptCommand;
1534
1535        let (mut app, mut rx) = make_app_with_config_recording(&mode_model_options("Planner", "gpt-4o"));
1536        app.update_config_options(&mode_model_options("Coder", "gpt-4o"));
1537
1538        app.on_acp_event(AcpEvent::NewSessionCreated {
1539            session_id: SessionId::new("new-session"),
1540            config_options: mode_model_options("Planner", "gpt-4o"),
1541        });
1542
1543        assert_eq!(app.session_id, SessionId::new("new-session"));
1544        assert!(app.context_usage.is_none());
1545
1546        let cmd = rx.try_recv().expect("expected a SetConfigOption command");
1547        match cmd {
1548            PromptCommand::SetConfigOption { config_id, value, .. } => {
1549                assert_eq!(config_id, "mode");
1550                assert_eq!(value, "Coder");
1551            }
1552            other => panic!("expected SetConfigOption, got {other:?}"),
1553        }
1554        assert!(rx.try_recv().is_err(), "model was unchanged, no extra command expected");
1555    }
1556
1557    #[tokio::test]
1558    async fn url_completion_appends_status_text_for_known_pending_id() {
1559        let mut app = make_app();
1560
1561        app.conversation_screen.pending_url_elicitations.insert(("github".to_string(), "el-1".to_string()));
1562
1563        let params = acp_utils::notifications::UrlElicitationCompleteParams {
1564            server_name: "github".to_string(),
1565            elicitation_id: "el-1".to_string(),
1566        };
1567        app.conversation_screen.on_url_elicitation_complete(&params);
1568
1569        let messages: Vec<_> = app
1570            .conversation_screen
1571            .conversation
1572            .segments()
1573            .filter_map(|seg| match seg {
1574                SegmentContent::UserMessage(text) if text.contains("github") && text.contains("finished") => Some(text),
1575                _ => None,
1576            })
1577            .collect();
1578        assert_eq!(messages.len(), 1, "should show completion message for known ID");
1579        assert!(messages[0].to_lowercase().contains("retry"), "completion message should mention retry");
1580    }
1581
1582    #[tokio::test]
1583    async fn url_completion_ignores_unknown_id() {
1584        let mut app = make_app();
1585
1586        // No pending elicitations registered
1587        let params = acp_utils::notifications::UrlElicitationCompleteParams {
1588            server_name: "unknown-server".to_string(),
1589            elicitation_id: "el-unknown".to_string(),
1590        };
1591        app.conversation_screen.on_url_elicitation_complete(&params);
1592
1593        let has_completion = app
1594            .conversation_screen
1595            .conversation
1596            .segments()
1597            .any(|seg| matches!(seg, SegmentContent::UserMessage(t) if t.contains("finished")));
1598        assert!(!has_completion, "should not show completion message for unknown ID");
1599    }
1600
1601    #[tokio::test]
1602    async fn url_completion_ignores_mismatched_server_name_for_known_id() {
1603        let mut app = make_app();
1604
1605        app.conversation_screen.pending_url_elicitations.insert(("github".to_string(), "el-1".to_string()));
1606
1607        let params = acp_utils::notifications::UrlElicitationCompleteParams {
1608            server_name: "linear".to_string(),
1609            elicitation_id: "el-1".to_string(),
1610        };
1611        app.conversation_screen.on_url_elicitation_complete(&params);
1612
1613        assert!(
1614            app.conversation_screen.pending_url_elicitations.contains(&("github".to_string(), "el-1".to_string())),
1615            "mismatched server name should not clear the pending elicitation"
1616        );
1617        let has_completion = app
1618            .conversation_screen
1619            .conversation
1620            .segments()
1621            .any(|seg| matches!(seg, SegmentContent::UserMessage(t) if t.contains("finished")));
1622        assert!(!has_completion, "should not show completion message for mismatched server name");
1623    }
1624
1625    #[tokio::test]
1626    async fn url_completion_ignores_duplicate_id() {
1627        let mut app = make_app();
1628
1629        app.conversation_screen.pending_url_elicitations.insert(("github".to_string(), "el-1".to_string()));
1630
1631        let params = acp_utils::notifications::UrlElicitationCompleteParams {
1632            server_name: "github".to_string(),
1633            elicitation_id: "el-1".to_string(),
1634        };
1635
1636        // First completion should show message
1637        app.conversation_screen.on_url_elicitation_complete(&params);
1638        // Second completion should be silently ignored (ID already removed)
1639        app.conversation_screen.on_url_elicitation_complete(&params);
1640
1641        let count = app
1642            .conversation_screen
1643            .conversation
1644            .segments()
1645            .filter(|seg| matches!(seg, SegmentContent::UserMessage(t) if t.contains("finished")))
1646            .count();
1647        assert_eq!(count, 1, "should show exactly one completion message, not duplicates");
1648    }
1649
1650    #[tokio::test(flavor = "current_thread")]
1651    async fn ctrl_g_blocked_during_url_elicitation_modal() {
1652        LocalSet::new()
1653            .run_until(async {
1654                let mut app = make_app();
1655                let (cx, mut peer) = test_connection().await;
1656                let (responder, _rx) = peer.fake_elicitation(&cx).await;
1657                app.conversation_screen.active_modal = Some(Modal::Elicitation(ElicitationForm::from_params(
1658                    url_elicitation_params("test-server", "el-1", "https://example.com/auth"),
1659                    responder,
1660                )));
1661
1662                send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1663                assert!(!app.screen_router.is_git_diff(), "git diff should not open during URL elicitation modal");
1664            })
1665            .await;
1666    }
1667
1668    #[tokio::test]
1669    async fn reset_after_context_cleared_clears_pending_url_elicitations() {
1670        let mut app = make_app();
1671        app.conversation_screen.pending_url_elicitations.insert(("github".to_string(), "el-1".to_string()));
1672        app.conversation_screen.pending_url_elicitations.insert(("linear".to_string(), "el-2".to_string()));
1673
1674        app.conversation_screen.reset_after_context_cleared();
1675
1676        assert!(
1677            app.conversation_screen.pending_url_elicitations.is_empty(),
1678            "pending URL elicitations should be cleared on reset"
1679        );
1680    }
1681
1682    #[tokio::test]
1683    async fn first_ctrl_c_clears_prompt_input() {
1684        let mut app = make_app();
1685        app.conversation_screen.prompt_composer.set_input("draft prompt".to_string());
1686
1687        send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await;
1688
1689        assert_eq!(app.conversation_screen.prompt_composer.buffer(), "");
1690        assert!(!app.exit_requested(), "first Ctrl-C should not exit");
1691        assert!(app.exit_confirmation_active(), "first Ctrl-C should activate confirmation");
1692    }
1693
1694    #[tokio::test]
1695    async fn first_ctrl_c_does_not_exit() {
1696        let mut app = make_app();
1697        send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await;
1698        assert!(!app.exit_requested(), "first Ctrl-C should not exit");
1699        assert!(app.exit_confirmation_active(), "first Ctrl-C should activate confirmation");
1700    }
1701
1702    #[tokio::test]
1703    async fn second_ctrl_c_exits() {
1704        let mut app = make_app();
1705        send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await;
1706        assert!(!app.exit_requested());
1707        send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await;
1708        assert!(app.exit_requested(), "second Ctrl-C should exit");
1709    }
1710
1711    #[tokio::test]
1712    async fn ctrl_c_confirmation_expires_on_tick() {
1713        let mut app = make_app();
1714        app.ctrl_c_pressed_at = Some(Instant::now().checked_sub(Duration::from_secs(4)).unwrap());
1715        assert!(app.exit_confirmation_active());
1716        app.on_event(&Event::Tick).await;
1717        assert!(!app.exit_confirmation_active(), "confirmation should expire after timeout");
1718    }
1719
1720    #[tokio::test]
1721    async fn prompt_error_mid_tool_call_finalizes_running_tools() {
1722        let (mut app, mut rx) = make_app_with_config_recording(&[]);
1723        app.conversation_screen.on_prompt_sent();
1724        app.on_session_update(&acp::SessionUpdate::ToolCall(acp::ToolCall::new("tool-1".to_string(), "slow_tool")));
1725        assert!(
1726            app.conversation_screen.tool_call_statuses.running_any(),
1727            "precondition: tool call should be tracked as running"
1728        );
1729
1730        app.on_acp_event(AcpEvent::PromptError(acp::Error::internal_error()));
1731
1732        assert!(
1733            !app.conversation_screen.tool_call_statuses.running_any(),
1734            "prompt error should finalize running tool calls like on_prompt_done does"
1735        );
1736
1737        let ctx = ViewContext::new((200, 24));
1738        app.conversation_screen.refresh_caches(&ctx);
1739        let frame = app.conversation_screen.progress_indicator.render(&ctx);
1740        assert!(
1741            frame.lines().is_empty(),
1742            "progress indicator must go idle after a prompt error; otherwise it renders an animated spinner \
1743             with '(esc to interrupt)' while Esc is dead (cancel is gated on waiting_for_response, already false)"
1744        );
1745
1746        send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1747        assert!(rx.try_recv().is_err(), "no cancel should be needed once the UI is idle");
1748    }
1749
1750    #[tokio::test]
1751    async fn esc_hint_renders_when_only_sub_agents_are_running() {
1752        use acp_utils::notifications::{SubAgentEvent, SubAgentProgressParams};
1753
1754        let (mut app, _rx) = make_app_with_config_recording(&[]);
1755        app.on_session_update(&acp::SessionUpdate::ToolCall(acp::ToolCall::new(
1756            "tool-1".to_string(),
1757            "spawn_subagent",
1758        )));
1759        app.conversation_screen.tool_call_statuses.on_sub_agent_progress(&SubAgentProgressParams {
1760            parent_tool_id: "tool-1".to_string(),
1761            task_id: "task-1".to_string(),
1762            agent_name: "researcher".to_string(),
1763            event: SubAgentEvent::Other,
1764        });
1765        assert!(app.conversation_screen.is_busy(), "precondition: sub-agent work counts as busy");
1766
1767        let ctx = ViewContext::new((200, 24));
1768        app.conversation_screen.refresh_caches(&ctx);
1769        let frame = app.conversation_screen.progress_indicator.render(&ctx);
1770        let rendered: String = frame.lines().iter().map(tui::Line::plain_text).collect();
1771        assert!(
1772            rendered.contains("esc to interrupt"),
1773            "the indicator must advertise Esc whenever the Esc gate would accept it; deriving indicator state \
1774             from top-level tool counts hid sub-agent-only activity"
1775        );
1776    }
1777
1778    #[tokio::test]
1779    async fn esc_cancels_while_tools_running_even_when_not_waiting() {
1780        use acp_utils::client::PromptCommand;
1781
1782        let (mut app, mut rx) = make_app_with_config_recording(&[]);
1783        app.on_session_update(&acp::SessionUpdate::ToolCall(acp::ToolCall::new("tool-1".to_string(), "slow_tool")));
1784        assert!(!app.conversation_screen.is_waiting());
1785
1786        send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1787
1788        let cmd = rx.try_recv().expect("Esc should send cancel whenever the UI advertises '(esc to interrupt)'");
1789        assert!(matches!(cmd, PromptCommand::Cancel { .. }));
1790    }
1791
1792    #[tokio::test(flavor = "current_thread")]
1793    async fn replacing_pending_elicitation_modal_cancels_previous_responder() {
1794        LocalSet::new()
1795            .run_until(async {
1796                let mut app = make_app();
1797                let (cx, mut peer) = test_connection().await;
1798                let (first_responder, first_rx) = peer.fake_elicitation(&cx).await;
1799                let (second_responder, _second_rx) = peer.fake_elicitation(&cx).await;
1800
1801                app.on_elicitation_request(
1802                    elicitation_params("server-a", "first", ElicitationSchema::builder().build().unwrap()),
1803                    first_responder,
1804                );
1805                app.on_elicitation_request(
1806                    elicitation_params("server-b", "second", ElicitationSchema::builder().build().unwrap()),
1807                    second_responder,
1808                );
1809
1810                // The requesting agent blocks awaiting this response; dropping the
1811                // responder without answering would wedge it forever.
1812                let first_response = first_rx.await.expect("replaced elicitation must receive a response");
1813                assert_eq!(first_response.action, acp_utils::notifications::ElicitationAction::Cancel);
1814            })
1815            .await;
1816    }
1817
1818    #[test]
1819    fn status_line_shows_warning_when_confirmation_active() {
1820        use crate::components::status_line::StatusLine;
1821        use crate::settings::StatusLineSettings;
1822        let options = vec![acp::SessionConfigOption::select(
1823            "model",
1824            "Model",
1825            "m1",
1826            vec![acp::SessionConfigSelectOption::new("m1", "M1")],
1827        )];
1828        let workspace_status = test_workspace_status();
1829        let resolved = StatusLineSettings::resolved_defaults();
1830        let status = StatusLine {
1831            workspace_status: &workspace_status,
1832            agent_name: "test-agent",
1833            config_options: &options,
1834            context_usage: None,
1835            waiting_for_response: false,
1836            unhealthy_server_count: 0,
1837            content_padding: DEFAULT_CONTENT_PADDING,
1838            exit_confirmation_active: true,
1839            settings: &resolved,
1840        };
1841        let context = ViewContext::new((120, 40));
1842        let frame = status.render(&context);
1843        let text = frame.lines()[0].plain_text();
1844        assert!(text.contains("Ctrl-C again to exit"), "should show warning, got: {text}");
1845        assert!(!text.contains("test-agent"), "should not show agent name during confirmation, got: {text}");
1846    }
1847}