Skip to main content

wisp/components/app/
mod.rs

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