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