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