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