Skip to main content

wisp/components/app/
mod.rs

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