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