Skip to main content

wisp/components/app/
mod.rs

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