Skip to main content

wisp/components/app/
mod.rs

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