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