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