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