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