Skip to main content

wisp/components/app/
mod.rs

1pub mod attachments;
2pub mod git_diff_mode;
3mod screen_router;
4mod view;
5
6pub use git_diff_mode::{GitDiffLoadState, GitDiffMode, GitDiffViewState, PatchFocus};
7use screen_router::ScreenRouter;
8use screen_router::ScreenRouterMessage;
9
10use crate::components::conversation_screen::ConversationScreen;
11use crate::components::conversation_screen::ConversationScreenMessage;
12use crate::keybindings::Keybindings;
13use crate::settings;
14use crate::settings::overlay::{SettingsMessage, SettingsOverlay};
15use acp_utils::client::{AcpEvent, AcpPromptHandle};
16use acp_utils::config_meta::SelectOptionMeta;
17use acp_utils::config_option_id::ConfigOptionId;
18use agent_client_protocol::{self as acp, SessionId};
19use attachments::build_attachment_blocks;
20use std::path::PathBuf;
21use std::time::Instant;
22use tokio::sync::oneshot;
23use tui::RendererCommand;
24use tui::{Component, Event, Frame, KeyEvent, ViewContext};
25
26#[derive(Debug, Clone)]
27pub struct PromptAttachment {
28    pub path: PathBuf,
29    pub display_name: String,
30}
31
32pub struct App {
33    agent_name: String,
34    context_usage_pct: Option<u8>,
35    exit_requested: bool,
36    conversation_screen: ConversationScreen,
37    prompt_capabilities: acp::PromptCapabilities,
38    config_options: Vec<acp::SessionConfigOption>,
39    server_statuses: Vec<acp_utils::notifications::McpServerStatusEntry>,
40    auth_methods: Vec<acp::AuthMethod>,
41    settings_overlay: Option<SettingsOverlay>,
42    screen_router: ScreenRouter,
43    keybindings: Keybindings,
44    session_id: SessionId,
45    prompt_handle: AcpPromptHandle,
46    working_dir: PathBuf,
47}
48
49impl App {
50    pub fn new(
51        session_id: SessionId,
52        agent_name: String,
53        prompt_capabilities: acp::PromptCapabilities,
54        config_options: &[acp::SessionConfigOption],
55        auth_methods: Vec<acp::AuthMethod>,
56        working_dir: PathBuf,
57        prompt_handle: AcpPromptHandle,
58    ) -> Self {
59        let keybindings = Keybindings::default();
60        Self {
61            agent_name,
62            context_usage_pct: None,
63            exit_requested: false,
64            conversation_screen: ConversationScreen::new(keybindings.clone()),
65            prompt_capabilities,
66            config_options: config_options.to_vec(),
67            server_statuses: Vec::new(),
68            auth_methods,
69            settings_overlay: None,
70            screen_router: ScreenRouter::new(GitDiffMode::new(working_dir.clone())),
71            keybindings,
72            session_id,
73            prompt_handle,
74            working_dir,
75        }
76    }
77
78    pub fn exit_requested(&self) -> bool {
79        self.exit_requested
80    }
81
82    pub fn has_settings_overlay(&self) -> bool {
83        self.settings_overlay.is_some()
84    }
85
86    pub fn needs_mouse_capture(&self) -> bool {
87        self.settings_overlay.is_some() || self.screen_router.is_git_diff()
88    }
89
90    pub fn wants_tick(&self) -> bool {
91        self.conversation_screen.wants_tick()
92    }
93
94    fn git_diff_mode_mut(&mut self) -> &mut GitDiffMode {
95        self.screen_router.git_diff_mode_mut()
96    }
97
98    pub fn on_acp_event(&mut self, event: AcpEvent) {
99        match event {
100            AcpEvent::SessionUpdate(update) => self.on_session_update(&update),
101            AcpEvent::ExtNotification(notification) => {
102                self.on_ext_notification(&notification);
103            }
104            AcpEvent::PromptDone(stop_reason) => self.on_prompt_done(stop_reason),
105            AcpEvent::PromptError(error) => {
106                self.conversation_screen.on_prompt_error(&error);
107            }
108            AcpEvent::ElicitationRequest {
109                params,
110                response_tx,
111            } => self.on_elicitation_request(params, response_tx),
112            AcpEvent::AuthenticateComplete { method_id } => {
113                self.on_authenticate_complete(&method_id);
114            }
115            AcpEvent::AuthenticateFailed { method_id, error } => {
116                self.on_authenticate_failed(&method_id, &error);
117            }
118            AcpEvent::SessionsListed { sessions } => {
119                let current_id = &self.session_id;
120                let filtered: Vec<_> = sessions
121                    .into_iter()
122                    .filter(|s| s.session_id != *current_id)
123                    .collect();
124                self.conversation_screen.open_session_picker(filtered);
125            }
126            // SessionLoaded intentionally does NOT restore previous config selections:
127            // when the user loads an existing session, the server's stored config for
128            // that session is authoritative.
129            AcpEvent::SessionLoaded {
130                session_id,
131                config_options,
132            } => {
133                self.session_id = session_id;
134                self.update_config_options(&config_options);
135            }
136            AcpEvent::NewSessionCreated {
137                session_id,
138                config_options,
139            } => {
140                let previous_selections = current_config_selections(&self.config_options);
141                self.session_id = session_id;
142                self.update_config_options(&config_options);
143                self.context_usage_pct = None;
144                self.restore_config_selections(&previous_selections);
145            }
146            AcpEvent::ConnectionClosed => {
147                self.exit_requested = true;
148            }
149        }
150    }
151
152    async fn handle_key(&mut self, commands: &mut Vec<RendererCommand>, key_event: KeyEvent) {
153        if self.keybindings.exit.matches(key_event) {
154            self.exit_requested = true;
155            return;
156        }
157
158        if self.keybindings.toggle_git_diff.matches(key_event)
159            && !self.conversation_screen.has_modal()
160        {
161            if let Some(msg) = self.screen_router.toggle_git_diff() {
162                self.handle_screen_router_message(commands, msg).await;
163            }
164            return;
165        }
166
167        let event = Event::Key(key_event);
168
169        if self.screen_router.is_git_diff() {
170            for msg in self
171                .screen_router
172                .on_event(&event)
173                .await
174                .unwrap_or_default()
175            {
176                self.handle_screen_router_message(commands, msg).await;
177            }
178        } else if self.settings_overlay.is_some() {
179            self.handle_settings_overlay_event(commands, &event).await;
180        } else {
181            let outcome = self.conversation_screen.on_event(&event).await;
182            let consumed = outcome.is_some();
183            self.handle_conversation_messages(commands, outcome).await;
184            if !consumed {
185                self.handle_fallthrough_keybindings(key_event);
186            }
187        }
188    }
189
190    async fn submit_prompt(&mut self, user_input: String, attachments: Vec<PromptAttachment>) {
191        let outcome = build_attachment_blocks(&attachments).await;
192        self.conversation_screen.conversation.push_user_message("");
193        self.conversation_screen
194            .conversation
195            .push_user_message(&user_input);
196        for placeholder in &outcome.transcript_placeholders {
197            self.conversation_screen
198                .conversation
199                .push_user_message(placeholder);
200        }
201        for w in outcome.warnings {
202            self.conversation_screen
203                .conversation
204                .push_user_message(&format!("[wisp] {w}"));
205        }
206
207        if let Some(message) = self.media_support_error(&outcome.blocks) {
208            self.conversation_screen.reject_local_prompt(&message);
209            return;
210        }
211
212        let _ = self.prompt_handle.prompt(
213            &self.session_id,
214            &user_input,
215            if outcome.blocks.is_empty() {
216                None
217            } else {
218                Some(outcome.blocks)
219            },
220        );
221    }
222
223    async fn handle_conversation_messages(
224        &mut self,
225        commands: &mut Vec<RendererCommand>,
226        outcome: Option<Vec<ConversationScreenMessage>>,
227    ) {
228        for msg in outcome.unwrap_or_default() {
229            match msg {
230                ConversationScreenMessage::SendPrompt {
231                    user_input,
232                    attachments,
233                } => {
234                    self.conversation_screen.waiting_for_response = true;
235                    self.submit_prompt(user_input, attachments).await;
236                }
237                ConversationScreenMessage::ClearScreen => {
238                    commands.push(RendererCommand::ClearScreen);
239                }
240                ConversationScreenMessage::NewSession => {
241                    commands.push(RendererCommand::ClearScreen);
242                    let _ = self.prompt_handle.new_session(&self.working_dir);
243                }
244                ConversationScreenMessage::OpenSettings => {
245                    self.open_settings_overlay();
246                }
247                ConversationScreenMessage::OpenSessionPicker => {
248                    let _ = self.prompt_handle.list_sessions();
249                }
250                ConversationScreenMessage::LoadSession { session_id, cwd } => {
251                    if let Err(e) = self.prompt_handle.load_session(&session_id, &cwd) {
252                        tracing::warn!("Failed to load session: {e}");
253                    }
254                }
255            }
256        }
257    }
258
259    fn handle_fallthrough_keybindings(&self, key_event: KeyEvent) {
260        if self.keybindings.cycle_reasoning.matches(key_event) {
261            if let Some((id, val)) = settings::cycle_reasoning_option(&self.config_options) {
262                let _ = self
263                    .prompt_handle
264                    .set_config_option(&self.session_id, &id, &val);
265            }
266            return;
267        }
268
269        if self.keybindings.cycle_mode.matches(key_event) {
270            if let Some((id, val)) = settings::cycle_quick_option(&self.config_options) {
271                let _ = self
272                    .prompt_handle
273                    .set_config_option(&self.session_id, &id, &val);
274            }
275            return;
276        }
277
278        if self.keybindings.cancel.matches(key_event)
279            && self.conversation_screen.is_waiting()
280            && let Err(e) = self.prompt_handle.cancel(&self.session_id)
281        {
282            tracing::warn!("Failed to send cancel: {e}");
283        }
284    }
285
286    async fn handle_settings_overlay_event(
287        &mut self,
288        commands: &mut Vec<RendererCommand>,
289        event: &Event,
290    ) {
291        let Some(ref mut overlay) = self.settings_overlay else {
292            return;
293        };
294        let messages = overlay.on_event(event).await.unwrap_or_default();
295
296        for msg in messages {
297            match msg {
298                SettingsMessage::Close => {
299                    self.settings_overlay = None;
300                    return;
301                }
302                SettingsMessage::SetConfigOption { config_id, value } => {
303                    let _ =
304                        self.prompt_handle
305                            .set_config_option(&self.session_id, &config_id, &value);
306                }
307                SettingsMessage::SetTheme(theme) => {
308                    commands.push(RendererCommand::SetTheme(theme));
309                }
310                SettingsMessage::AuthenticateServer(name) => {
311                    let _ = self
312                        .prompt_handle
313                        .authenticate_mcp_server(&self.session_id, &name);
314                }
315                SettingsMessage::AuthenticateProvider(ref method_id) => {
316                    if let Some(ref mut overlay) = self.settings_overlay {
317                        overlay.on_authenticate_started(method_id);
318                    }
319                    let _ = self.prompt_handle.authenticate(&self.session_id, method_id);
320                }
321            }
322        }
323    }
324
325    fn open_settings_overlay(&mut self) {
326        self.settings_overlay = Some(settings::create_overlay(
327            &self.config_options,
328            &self.server_statuses,
329            &self.auth_methods,
330        ));
331    }
332
333    fn update_config_options(&mut self, config_options: &[acp::SessionConfigOption]) {
334        self.config_options = config_options.to_vec();
335        if let Some(ref mut overlay) = self.settings_overlay {
336            overlay.update_config_options(config_options);
337        }
338    }
339
340    fn update_auth_methods(&mut self, auth_methods: Vec<acp::AuthMethod>) {
341        self.auth_methods = auth_methods;
342        if let Some(ref mut overlay) = self.settings_overlay {
343            overlay.update_auth_methods(self.auth_methods.clone());
344        }
345    }
346
347    fn restore_config_selections(&self, previous: &[(String, String)]) {
348        let new_selections = current_config_selections(&self.config_options);
349        for (id, old_value) in previous {
350            let still_exists = new_selections.iter().any(|(new_id, _)| new_id == id);
351            if !still_exists {
352                tracing::debug!(
353                    config_id = id,
354                    "config option no longer present in new session"
355                );
356                continue;
357            }
358            let server_reset = new_selections
359                .iter()
360                .any(|(new_id, new_val)| new_id == id && new_val != old_value);
361            if server_reset
362                && let Err(e) =
363                    self.prompt_handle
364                        .set_config_option(&self.session_id, id, old_value)
365            {
366                tracing::warn!(config_id = id, error = %e, "failed to restore config option");
367            }
368        }
369    }
370
371    async fn handle_screen_router_message(
372        &mut self,
373        commands: &mut Vec<RendererCommand>,
374        msg: ScreenRouterMessage,
375    ) {
376        match msg {
377            ScreenRouterMessage::LoadGitDiff | ScreenRouterMessage::RefreshGitDiff => {
378                self.git_diff_mode_mut().complete_load().await;
379            }
380            ScreenRouterMessage::SendPrompt { user_input } => {
381                if self.conversation_screen.is_waiting() {
382                    return;
383                }
384
385                self.conversation_screen.waiting_for_response = true;
386                self.submit_prompt(user_input, Vec::new()).await;
387                self.screen_router.close_git_diff();
388            }
389        }
390        let _ = commands;
391    }
392
393    fn on_session_update(&mut self, update: &acp::SessionUpdate) {
394        self.conversation_screen.on_session_update(update);
395
396        if let acp::SessionUpdate::ConfigOptionUpdate(config_update) = update {
397            self.update_config_options(&config_update.config_options);
398        }
399    }
400
401    fn on_prompt_done(&mut self, stop_reason: acp::StopReason) {
402        self.conversation_screen.on_prompt_done(stop_reason);
403    }
404
405    fn on_elicitation_request(
406        &mut self,
407        params: acp_utils::notifications::ElicitationParams,
408        response_tx: oneshot::Sender<acp_utils::notifications::ElicitationResponse>,
409    ) {
410        self.settings_overlay = None;
411        self.conversation_screen
412            .on_elicitation_request(params, response_tx);
413    }
414
415    fn on_ext_notification(&mut self, notification: &acp::ExtNotification) {
416        use acp_utils::notifications::{
417            AUTH_METHODS_UPDATED_METHOD, AuthMethodsUpdatedParams, CONTEXT_CLEARED_METHOD,
418            CONTEXT_USAGE_METHOD, ContextUsageParams, McpNotification, SUB_AGENT_PROGRESS_METHOD,
419            SubAgentProgressParams,
420        };
421
422        match notification.method.as_ref() {
423            CONTEXT_CLEARED_METHOD => {
424                self.conversation_screen.reset_after_context_cleared();
425                self.context_usage_pct = None;
426            }
427            CONTEXT_USAGE_METHOD => {
428                if let Ok(params) =
429                    serde_json::from_str::<ContextUsageParams>(notification.params.get())
430                {
431                    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
432                    {
433                        self.context_usage_pct = params.usage_ratio.map(|usage_ratio| {
434                            ((1.0 - usage_ratio) * 100.0).clamp(0.0, 100.0).round() as u8
435                        });
436                    }
437                }
438            }
439            SUB_AGENT_PROGRESS_METHOD => {
440                if let Ok(progress) =
441                    serde_json::from_str::<SubAgentProgressParams>(notification.params.get())
442                {
443                    self.conversation_screen.on_sub_agent_progress(&progress);
444                }
445            }
446            AUTH_METHODS_UPDATED_METHOD => {
447                if let Ok(params) = AuthMethodsUpdatedParams::try_from(notification) {
448                    self.update_auth_methods(params.auth_methods);
449                }
450            }
451            _ => {
452                if let Ok(McpNotification::ServerStatus { servers }) =
453                    McpNotification::try_from(notification)
454                {
455                    if let Some(ref mut overlay) = self.settings_overlay {
456                        overlay.update_server_statuses(servers.clone());
457                    }
458                    self.server_statuses = servers;
459                }
460            }
461        }
462    }
463
464    fn on_authenticate_complete(&mut self, method_id: &str) {
465        if let Some(ref mut overlay) = self.settings_overlay {
466            overlay.on_authenticate_complete(method_id);
467        }
468    }
469
470    fn on_authenticate_failed(&mut self, method_id: &str, error: &str) {
471        tracing::warn!("Provider auth failed for {method_id}: {error}");
472        if let Some(ref mut overlay) = self.settings_overlay {
473            overlay.on_authenticate_failed(method_id);
474        }
475    }
476
477    fn media_support_error(&self, blocks: &[acp::ContentBlock]) -> Option<String> {
478        let requires_image = blocks
479            .iter()
480            .any(|block| matches!(block, acp::ContentBlock::Image(_)));
481        let requires_audio = blocks
482            .iter()
483            .any(|block| matches!(block, acp::ContentBlock::Audio(_)));
484
485        if !requires_image && !requires_audio {
486            return None;
487        }
488
489        if requires_image && !self.prompt_capabilities.image {
490            return Some("ACP agent does not support image input.".to_string());
491        }
492        if requires_audio && !self.prompt_capabilities.audio {
493            return Some("ACP agent does not support audio input.".to_string());
494        }
495
496        let option = self
497            .config_options
498            .iter()
499            .find(|option| option.id.0.as_ref() == ConfigOptionId::Model.as_str())?;
500        let acp::SessionConfigKind::Select(select) = &option.kind else {
501            return None;
502        };
503
504        let values: Vec<_> = select
505            .current_value
506            .0
507            .split(',')
508            .map(str::trim)
509            .filter(|value| !value.is_empty())
510            .collect();
511
512        if values.is_empty() {
513            return None;
514        }
515
516        let acp::SessionConfigSelectOptions::Ungrouped(options) = &select.options else {
517            return None;
518        };
519
520        let selected_meta: Vec<_> = values
521            .iter()
522            .filter_map(|value| {
523                options
524                    .iter()
525                    .find(|option| option.value.0.as_ref() == *value)
526                    .map(|option| SelectOptionMeta::from_meta(option.meta.as_ref()))
527            })
528            .collect();
529
530        if selected_meta.len() != values.len() {
531            return Some("Current model selection is missing prompt capability metadata.".into());
532        }
533
534        if requires_image && selected_meta.iter().any(|meta| !meta.supports_image) {
535            return Some("Current model selection does not support image input.".to_string());
536        }
537        if requires_audio && selected_meta.iter().any(|meta| !meta.supports_audio) {
538            return Some("Current model selection does not support audio input.".to_string());
539        }
540
541        None
542    }
543}
544
545impl Component for App {
546    type Message = RendererCommand;
547
548    async fn on_event(&mut self, event: &Event) -> Option<Vec<RendererCommand>> {
549        let mut commands = Vec::new();
550        match event {
551            Event::Key(key_event) => self.handle_key(&mut commands, *key_event).await,
552            Event::Paste(_) => {
553                self.settings_overlay = None;
554                let outcome = self.conversation_screen.on_event(event).await;
555                self.handle_conversation_messages(&mut commands, outcome)
556                    .await;
557            }
558            Event::Tick => {
559                let now = Instant::now();
560                self.conversation_screen.on_tick(now);
561            }
562            Event::Mouse(_) => {
563                if self.screen_router.is_git_diff() {
564                    for msg in self.screen_router.on_event(event).await.unwrap_or_default() {
565                        self.handle_screen_router_message(&mut commands, msg).await;
566                    }
567                } else if self.settings_overlay.is_some() {
568                    self.handle_settings_overlay_event(&mut commands, event)
569                        .await;
570                }
571            }
572            Event::Resize(_) => {}
573        }
574        Some(commands)
575    }
576
577    fn render(&mut self, ctx: &ViewContext) -> Frame {
578        self.conversation_screen.refresh_caches(ctx);
579        self.screen_router.refresh_caches(ctx);
580
581        let height = (ctx.size.height.saturating_sub(1)) as usize;
582        if let Some(ref mut overlay) = self.settings_overlay
583            && height >= 3
584        {
585            overlay.update_child_viewport(height.saturating_sub(4));
586        }
587
588        view::build_frame(self, ctx)
589    }
590}
591
592fn current_config_selections(options: &[acp::SessionConfigOption]) -> Vec<(String, String)> {
593    options
594        .iter()
595        .filter_map(|opt| {
596            let acp::SessionConfigKind::Select(ref select) = opt.kind else {
597                return None;
598            };
599            Some((opt.id.0.to_string(), select.current_value.0.to_string()))
600        })
601        .collect()
602}
603
604#[cfg(test)]
605pub(crate) mod test_helpers {
606    use super::*;
607
608    pub fn make_app() -> App {
609        App::new(
610            SessionId::new("test"),
611            "test-agent".to_string(),
612            acp::PromptCapabilities::new(),
613            &[],
614            vec![],
615            PathBuf::from("."),
616            AcpPromptHandle::noop(),
617        )
618    }
619
620    pub fn make_app_with_config(config_options: &[acp::SessionConfigOption]) -> App {
621        App::new(
622            SessionId::new("test"),
623            "test-agent".to_string(),
624            acp::PromptCapabilities::new(),
625            config_options,
626            vec![],
627            PathBuf::from("."),
628            AcpPromptHandle::noop(),
629        )
630    }
631
632    pub fn make_app_with_auth(auth_methods: Vec<acp::AuthMethod>) -> App {
633        App::new(
634            SessionId::new("test"),
635            "test-agent".to_string(),
636            acp::PromptCapabilities::new(),
637            &[],
638            auth_methods,
639            PathBuf::from("."),
640            AcpPromptHandle::noop(),
641        )
642    }
643
644    pub fn make_app_with_config_recording(
645        config_options: &[acp::SessionConfigOption],
646    ) -> (
647        App,
648        tokio::sync::mpsc::UnboundedReceiver<acp_utils::client::PromptCommand>,
649    ) {
650        let (handle, rx) = AcpPromptHandle::recording();
651        let app = App::new(
652            SessionId::new("test"),
653            "test-agent".to_string(),
654            acp::PromptCapabilities::new(),
655            config_options,
656            vec![],
657            PathBuf::from("."),
658            handle,
659        );
660        (app, rx)
661    }
662
663    pub fn make_app_with_session_id(session_id: &str) -> App {
664        App::new(
665            SessionId::new(session_id),
666            "test-agent".to_string(),
667            acp::PromptCapabilities::new(),
668            &[],
669            vec![],
670            PathBuf::from("."),
671            AcpPromptHandle::noop(),
672        )
673    }
674
675    pub fn make_app_with_config_and_capabilities_recording(
676        config_options: &[acp::SessionConfigOption],
677        prompt_capabilities: acp::PromptCapabilities,
678    ) -> (
679        App,
680        tokio::sync::mpsc::UnboundedReceiver<acp_utils::client::PromptCommand>,
681    ) {
682        let (handle, rx) = AcpPromptHandle::recording();
683        let app = App::new(
684            SessionId::new("test"),
685            "test-agent".to_string(),
686            prompt_capabilities,
687            config_options,
688            vec![],
689            PathBuf::from("."),
690            handle,
691        );
692        (app, rx)
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::test_helpers::*;
699    use super::*;
700    use crate::components::command_picker::CommandEntry;
701    use crate::components::elicitation_form::ElicitationForm;
702    use crate::settings::{ThemeSettings as WispThemeSettings, WispSettings, save_settings};
703    use crate::test_helpers::with_wisp_home;
704    use std::fs;
705    use std::time::Duration;
706    use tempfile::TempDir;
707    use tui::testing::render_component;
708    use tui::{Frame, KeyCode, KeyModifiers, Renderer, Theme, ViewContext};
709
710    fn make_renderer() -> Renderer<Vec<u8>> {
711        Renderer::new(Vec::new(), Theme::default(), (80, 24))
712    }
713
714    fn render_app(renderer: &mut Renderer<Vec<u8>>, app: &mut App, context: &ViewContext) -> Frame {
715        renderer.render_frame(|ctx| app.render(ctx)).unwrap();
716        app.render(context)
717    }
718
719    fn frame_contains(output: &Frame, text: &str) -> bool {
720        output
721            .lines()
722            .iter()
723            .any(|line| line.plain_text().contains(text))
724    }
725
726    async fn send_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
727        app.on_event(&Event::Key(KeyEvent::new(code, modifiers)))
728            .await;
729    }
730
731    fn setup_themes_dir(files: &[&str]) -> TempDir {
732        let temp_dir = TempDir::new().unwrap();
733        let themes_dir = temp_dir.path().join("themes");
734        fs::create_dir_all(&themes_dir).unwrap();
735        for f in files {
736            fs::write(themes_dir.join(f), "x").unwrap();
737        }
738        temp_dir
739    }
740
741    fn make_plan_entry(name: &str, status: acp::PlanEntryStatus) -> acp::PlanEntry {
742        acp::PlanEntry::new(name, acp::PlanEntryPriority::Medium, status)
743    }
744
745    fn mode_model_options(
746        mode_val: impl Into<String>,
747        model_val: impl Into<String>,
748    ) -> Vec<acp::SessionConfigOption> {
749        vec![
750            acp::SessionConfigOption::select(
751                "mode",
752                "Mode",
753                mode_val.into(),
754                vec![
755                    acp::SessionConfigSelectOption::new("Planner", "Planner"),
756                    acp::SessionConfigSelectOption::new("Coder", "Coder"),
757                ],
758            )
759            .category(acp::SessionConfigOptionCategory::Mode),
760            acp::SessionConfigOption::select(
761                "model",
762                "Model",
763                model_val.into(),
764                vec![
765                    acp::SessionConfigSelectOption::new("gpt-4o", "GPT-4o"),
766                    acp::SessionConfigSelectOption::new("claude", "Claude"),
767                ],
768            )
769            .category(acp::SessionConfigOptionCategory::Model),
770        ]
771    }
772
773    fn image_model_options() -> Vec<acp::SessionConfigOption> {
774        vec![
775            acp::SessionConfigOption::select(
776                "model",
777                "Model",
778                "anthropic:claude-sonnet-4-5",
779                vec![
780                    acp::SessionConfigSelectOption::new(
781                        "anthropic:claude-sonnet-4-5",
782                        "Claude Sonnet",
783                    )
784                    .meta(
785                        SelectOptionMeta {
786                            reasoning_levels: vec![],
787                            supports_image: true,
788                            supports_audio: false,
789                        }
790                        .into_meta(),
791                    ),
792                    acp::SessionConfigSelectOption::new("deepseek:deepseek-chat", "DeepSeek").meta(
793                        SelectOptionMeta {
794                            reasoning_levels: vec![],
795                            supports_image: false,
796                            supports_audio: false,
797                        }
798                        .into_meta(),
799                    ),
800                ],
801            )
802            .category(acp::SessionConfigOptionCategory::Model),
803        ]
804    }
805
806    #[test]
807    fn settings_overlay_with_themes() {
808        let temp_dir = setup_themes_dir(&["catppuccin.tmTheme"]);
809        with_wisp_home(temp_dir.path(), || {
810            let mut app = make_app();
811            app.open_settings_overlay();
812            assert!(app.settings_overlay.is_some());
813        });
814
815        let temp_dir = setup_themes_dir(&["catppuccin.tmTheme", "nord.tmTheme"]);
816        with_wisp_home(temp_dir.path(), || {
817            let settings = WispSettings {
818                theme: WispThemeSettings {
819                    file: Some("nord.tmTheme".to_string()),
820                },
821            };
822            save_settings(&settings).unwrap();
823            let mut app = make_app();
824            app.open_settings_overlay();
825            assert!(app.settings_overlay.is_some());
826        });
827    }
828
829    #[test]
830    fn command_picker_cursor_stays_in_input_prompt() {
831        let mut app = make_app();
832        let mut renderer = make_renderer();
833        app.conversation_screen
834            .prompt_composer
835            .open_command_picker_with_entries(vec![CommandEntry {
836                name: "settings".to_string(),
837                description: "Open settings".to_string(),
838                has_input: false,
839                hint: None,
840                builtin: true,
841            }]);
842
843        let context = ViewContext::new((120, 40));
844        let output = render_app(&mut renderer, &mut app, &context);
845        let input_row = output
846            .lines()
847            .iter()
848            .position(|line| line.plain_text().contains("> "))
849            .expect("input prompt should exist");
850        assert_eq!(output.cursor().row, input_row);
851    }
852
853    #[test]
854    fn settings_overlay_replaces_conversation_window() {
855        let options = vec![acp::SessionConfigOption::select(
856            "model",
857            "Model",
858            "m1",
859            vec![acp::SessionConfigSelectOption::new("m1", "M1")],
860        )];
861        let mut app = make_app_with_config(&options);
862        let mut renderer = make_renderer();
863        app.open_settings_overlay();
864
865        let ctx = ViewContext::new((120, 40));
866        assert!(frame_contains(
867            &render_app(&mut renderer, &mut app, &ctx),
868            "Configuration"
869        ));
870        app.settings_overlay = None;
871        assert!(!frame_contains(
872            &render_app(&mut renderer, &mut app, &ctx),
873            "Configuration"
874        ));
875    }
876
877    #[test]
878    fn extract_model_display_handles_comma_separated_value() {
879        use crate::components::status_line::extract_model_display;
880        let options = vec![acp::SessionConfigOption::select(
881            "model",
882            "Model",
883            "a:x,b:y",
884            vec![
885                acp::SessionConfigSelectOption::new("a:x", "Alpha / X"),
886                acp::SessionConfigSelectOption::new("b:y", "Beta / Y"),
887                acp::SessionConfigSelectOption::new("c:z", "Gamma / Z"),
888            ],
889        )];
890        assert_eq!(
891            extract_model_display(&options).as_deref(),
892            Some("Alpha / X + Beta / Y")
893        );
894    }
895
896    #[test]
897    fn extract_reasoning_effort_returns_none_for_none_value() {
898        use crate::components::status_line::extract_reasoning_effort;
899        use acp_utils::config_option_id::ConfigOptionId;
900        let options = vec![acp::SessionConfigOption::select(
901            ConfigOptionId::ReasoningEffort.as_str(),
902            "Reasoning",
903            "none",
904            vec![
905                acp::SessionConfigSelectOption::new("none", "None"),
906                acp::SessionConfigSelectOption::new("low", "Low"),
907            ],
908        )];
909        assert_eq!(extract_reasoning_effort(&options), None);
910    }
911
912    #[test]
913    fn render_hides_plan_header_when_no_entries_are_visible() {
914        let mut app = make_app();
915        let mut renderer = make_renderer();
916        let grace_period = app.conversation_screen.plan_tracker.grace_period;
917        app.conversation_screen.plan_tracker.replace(
918            vec![make_plan_entry("1", acp::PlanEntryStatus::Completed)],
919            Instant::now()
920                .checked_sub(grace_period + Duration::from_millis(1))
921                .unwrap(),
922        );
923        app.conversation_screen.plan_tracker.on_tick(Instant::now());
924
925        let output = render_app(&mut renderer, &mut app, &ViewContext::new((120, 40)));
926        assert!(!frame_contains(&output, "Plan"));
927    }
928
929    #[test]
930    fn plan_version_increments_on_replace_and_clear() {
931        let mut app = make_app();
932        let v0 = app.conversation_screen.plan_tracker.version();
933
934        app.conversation_screen.plan_tracker.replace(
935            vec![make_plan_entry("Task A", acp::PlanEntryStatus::Pending)],
936            Instant::now(),
937        );
938        let v1 = app.conversation_screen.plan_tracker.version();
939        assert!(v1 > v0, "replace should increment version");
940
941        app.conversation_screen.plan_tracker.clear();
942        assert!(
943            app.conversation_screen.plan_tracker.version() > v1,
944            "clear should increment version"
945        );
946    }
947
948    #[test]
949    fn sessions_listed_filters_out_current_session() {
950        let mut app = make_app_with_session_id("current-session");
951        app.on_acp_event(AcpEvent::SessionsListed {
952            sessions: vec![
953                acp::SessionInfo::new("other-session-1", PathBuf::from("/project"))
954                    .title("First other session".to_string()),
955                acp::SessionInfo::new("current-session", PathBuf::from("/project"))
956                    .title("Current session title".to_string()),
957                acp::SessionInfo::new("other-session-2", PathBuf::from("/other"))
958                    .title("Second other session".to_string()),
959            ],
960        });
961
962        let picker = match &mut app.conversation_screen.active_modal {
963            Some(crate::components::conversation_screen::Modal::SessionPicker(p)) => p,
964            _ => panic!("expected session picker modal"),
965        };
966        let lines = render_component(|ctx| picker.render(ctx), 60, 10).get_lines();
967
968        let has = |text: &str| lines.iter().any(|l| l.contains(text));
969        assert!(
970            !has("Current session title"),
971            "current session should be filtered out"
972        );
973        assert!(
974            has("First other session"),
975            "first other session should be present"
976        );
977        assert!(
978            has("Second other session"),
979            "second other session should be present"
980        );
981    }
982
983    #[tokio::test]
984    async fn custom_exit_keybinding_triggers_exit() {
985        use crate::keybindings::KeyBinding;
986        let mut app = make_app();
987        app.keybindings.exit = KeyBinding::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
988
989        send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await;
990        assert!(
991            !app.exit_requested(),
992            "default Ctrl+C should no longer exit"
993        );
994
995        send_key(&mut app, KeyCode::Char('q'), KeyModifiers::CONTROL).await;
996        assert!(app.exit_requested(), "custom Ctrl+Q should exit");
997    }
998
999    #[tokio::test]
1000    async fn ctrl_g_toggles_git_diff_viewer() {
1001        let mut app = make_app();
1002
1003        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1004        assert!(app.screen_router.is_git_diff(), "should open git diff");
1005
1006        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1007        assert!(!app.screen_router.is_git_diff(), "should close git diff");
1008    }
1009
1010    #[tokio::test]
1011    async fn needs_mouse_capture_in_git_diff() {
1012        let mut app = make_app();
1013        assert!(!app.needs_mouse_capture());
1014
1015        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1016        assert!(app.needs_mouse_capture());
1017
1018        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1019        assert!(!app.needs_mouse_capture());
1020    }
1021
1022    #[tokio::test]
1023    async fn ctrl_g_blocked_during_elicitation() {
1024        let mut app = make_app();
1025        app.conversation_screen.active_modal =
1026            Some(crate::components::conversation_screen::Modal::Elicitation(
1027                ElicitationForm::from_params(
1028                    acp_utils::notifications::ElicitationParams {
1029                        message: "test".to_string(),
1030                        schema: acp_utils::ElicitationSchema::builder().build().unwrap(),
1031                    },
1032                    tokio::sync::oneshot::channel().0,
1033                ),
1034            ));
1035
1036        send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1037        assert!(
1038            !app.screen_router.is_git_diff(),
1039            "git diff should not open during elicitation"
1040        );
1041    }
1042
1043    #[tokio::test]
1044    async fn esc_in_diff_mode_does_not_cancel() {
1045        let mut app = make_app();
1046        app.conversation_screen.waiting_for_response = true;
1047        app.screen_router.enter_git_diff_for_test();
1048
1049        send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1050
1051        assert!(!app.exit_requested());
1052        assert!(
1053            app.conversation_screen.waiting_for_response,
1054            "Esc should NOT cancel a running prompt while git diff mode is active"
1055        );
1056    }
1057
1058    #[tokio::test]
1059    async fn git_diff_submit_sends_prompt_and_closes_diff_when_idle() {
1060        use acp_utils::client::PromptCommand;
1061
1062        let (mut app, mut rx) = make_app_with_config_recording(&[]);
1063        app.screen_router.enter_git_diff_for_test();
1064
1065        let mut commands = Vec::new();
1066        app.handle_screen_router_message(
1067            &mut commands,
1068            ScreenRouterMessage::SendPrompt {
1069                user_input: "Looks good".to_string(),
1070            },
1071        )
1072        .await;
1073
1074        assert!(
1075            !app.screen_router.is_git_diff(),
1076            "successful submit should exit git diff mode"
1077        );
1078        assert!(
1079            app.conversation_screen.waiting_for_response,
1080            "submit should transition into waiting state"
1081        );
1082
1083        let cmd = rx.try_recv().expect("expected Prompt command to be sent");
1084        match cmd {
1085            PromptCommand::Prompt { text, .. } => {
1086                assert!(text.contains("Looks good"));
1087            }
1088            other => panic!("expected Prompt command, got {other:?}"),
1089        }
1090    }
1091
1092    #[tokio::test]
1093    async fn git_diff_submit_while_waiting_is_ignored_and_keeps_diff_open() {
1094        let (mut app, mut rx) = make_app_with_config_recording(&[]);
1095        app.conversation_screen.waiting_for_response = true;
1096        app.screen_router.enter_git_diff_for_test();
1097
1098        let mut commands = Vec::new();
1099        app.handle_screen_router_message(
1100            &mut commands,
1101            ScreenRouterMessage::SendPrompt {
1102                user_input: "Needs follow-up".to_string(),
1103            },
1104        )
1105        .await;
1106
1107        assert!(
1108            app.screen_router.is_git_diff(),
1109            "blocked submit should keep git diff mode open"
1110        );
1111        assert!(
1112            rx.try_recv().is_err(),
1113            "no prompt should be sent while waiting"
1114        );
1115    }
1116
1117    #[tokio::test]
1118    async fn mouse_scroll_ignored_in_conversation_mode() {
1119        use tui::{MouseEvent, MouseEventKind};
1120        let mut app = make_app();
1121        let mouse = MouseEvent {
1122            kind: MouseEventKind::ScrollDown,
1123            column: 0,
1124            row: 0,
1125            modifiers: KeyModifiers::NONE,
1126        };
1127        app.on_event(&Event::Mouse(mouse)).await;
1128    }
1129
1130    #[tokio::test]
1131    async fn prompt_composer_submit_pushes_echo_lines() {
1132        use crate::components::conversation_window::SegmentContent;
1133        let mut app = make_app();
1134        let mut commands = Vec::new();
1135        app.handle_conversation_messages(
1136            &mut commands,
1137            Some(vec![ConversationScreenMessage::SendPrompt {
1138                user_input: "hello".to_string(),
1139                attachments: vec![],
1140            }]),
1141        )
1142        .await;
1143
1144        let has_hello = app
1145            .conversation_screen
1146            .conversation
1147            .segments()
1148            .any(|seg| matches!(seg, SegmentContent::UserMessage(text) if text == "hello"));
1149        assert!(
1150            has_hello,
1151            "conversation buffer should contain the user input"
1152        );
1153    }
1154
1155    #[tokio::test]
1156    async fn unsupported_media_is_blocked_locally() {
1157        let (mut app, mut rx) = make_app_with_config_and_capabilities_recording(
1158            &image_model_options(),
1159            acp::PromptCapabilities::new().image(true).audio(false),
1160        );
1161        let mut commands = Vec::new();
1162        let temp = tempfile::tempdir().unwrap();
1163        let audio_path = temp.path().join("clip.wav");
1164        std::fs::write(&audio_path, b"fake wav").unwrap();
1165
1166        app.handle_conversation_messages(
1167            &mut commands,
1168            Some(vec![ConversationScreenMessage::SendPrompt {
1169                user_input: "listen".to_string(),
1170                attachments: vec![PromptAttachment {
1171                    path: audio_path,
1172                    display_name: "clip.wav".to_string(),
1173                }],
1174            }]),
1175        )
1176        .await;
1177
1178        assert!(rx.try_recv().is_err(), "prompt should be blocked locally");
1179        assert!(!app.conversation_screen.waiting_for_response);
1180        let messages: Vec<_> = app
1181            .conversation_screen
1182            .conversation
1183            .segments()
1184            .filter_map(|segment| match segment {
1185                crate::components::conversation_window::SegmentContent::UserMessage(text) => {
1186                    Some(text.clone())
1187                }
1188                _ => None,
1189            })
1190            .collect();
1191        assert!(messages.iter().any(|text| text == "listen"));
1192        assert!(
1193            messages
1194                .iter()
1195                .any(|text| text == "[audio attachment: clip.wav]")
1196        );
1197        assert!(messages.iter().any(|text| {
1198            text == "[wisp] ACP agent does not support audio input."
1199                || text == "[wisp] Current model selection does not support audio input."
1200        }));
1201    }
1202
1203    #[test]
1204    fn replayed_media_user_chunks_render_placeholders() {
1205        use crate::components::conversation_window::SegmentContent;
1206        let mut app = make_app();
1207
1208        app.on_session_update(&acp::SessionUpdate::UserMessageChunk(
1209            acp::ContentChunk::new(acp::ContentBlock::Image(acp::ImageContent::new(
1210                "aW1n",
1211                "image/png",
1212            ))),
1213        ));
1214        app.on_session_update(&acp::SessionUpdate::UserMessageChunk(
1215            acp::ContentChunk::new(acp::ContentBlock::Audio(acp::AudioContent::new(
1216                "YXVkaW8=",
1217                "audio/wav",
1218            ))),
1219        ));
1220
1221        let segments: Vec<_> = app.conversation_screen.conversation.segments().collect();
1222        assert!(matches!(
1223            segments[0],
1224            SegmentContent::UserMessage(text) if text == "[image attachment]"
1225        ));
1226        assert!(matches!(
1227            segments[1],
1228            SegmentContent::UserMessage(text) if text == "[audio attachment]"
1229        ));
1230    }
1231
1232    #[test]
1233    fn prompt_composer_open_settings() {
1234        let mut app = make_app();
1235        let mut commands = Vec::new();
1236        tokio::runtime::Runtime::new()
1237            .unwrap()
1238            .block_on(app.handle_conversation_messages(
1239                &mut commands,
1240                Some(vec![ConversationScreenMessage::OpenSettings]),
1241            ));
1242        assert!(
1243            app.settings_overlay.is_some(),
1244            "settings overlay should be opened"
1245        );
1246    }
1247
1248    #[test]
1249    fn settings_overlay_close_clears_overlay() {
1250        let mut app = make_app();
1251        app.open_settings_overlay();
1252        app.settings_overlay = None;
1253        assert!(app.settings_overlay.is_none(), "close should clear overlay");
1254    }
1255
1256    #[tokio::test]
1257    async fn tick_advances_spinner_animations() {
1258        let mut app = make_app();
1259        let tool_call = acp::ToolCall::new("tool-1".to_string(), "test_tool");
1260        app.conversation_screen
1261            .tool_call_statuses
1262            .on_tool_call(&tool_call);
1263        app.conversation_screen
1264            .progress_indicator
1265            .update(0, 1, true);
1266
1267        let ctx = ViewContext::new((80, 24));
1268        let tool_before = app
1269            .conversation_screen
1270            .tool_call_statuses
1271            .render_tool("tool-1", &ctx);
1272        let prog_before = app.conversation_screen.progress_indicator.render(&ctx);
1273
1274        app.on_event(&Event::Tick).await;
1275
1276        let tool_after = app
1277            .conversation_screen
1278            .tool_call_statuses
1279            .render_tool("tool-1", &ctx);
1280        let prog_after = app.conversation_screen.progress_indicator.render(&ctx);
1281
1282        assert_ne!(
1283            tool_before[0].plain_text(),
1284            tool_after[0].plain_text(),
1285            "tick should advance tool spinner"
1286        );
1287        assert_ne!(
1288            prog_before[0].plain_text(),
1289            prog_after[0].plain_text(),
1290            "tick should advance progress spinner"
1291        );
1292    }
1293
1294    #[test]
1295    fn on_prompt_error_clears_waiting_state() {
1296        let mut app = make_app();
1297        app.conversation_screen.waiting_for_response = true;
1298        app.conversation_screen
1299            .on_prompt_error(&acp::Error::internal_error());
1300        assert!(!app.conversation_screen.waiting_for_response);
1301        assert!(!app.exit_requested());
1302    }
1303
1304    #[test]
1305    fn auth_events_and_connection_close_exit_behavior() {
1306        let mut app = make_app_with_auth(vec![acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
1307            "anthropic",
1308            "Anthropic",
1309        ))]);
1310        app.on_authenticate_complete("anthropic");
1311        assert!(
1312            !app.exit_requested(),
1313            "authenticate_complete should not exit"
1314        );
1315
1316        let mut app = make_app();
1317        app.on_authenticate_failed("anthropic", "bad token");
1318        assert!(!app.exit_requested(), "authenticate_failed should not exit");
1319
1320        let mut app = make_app();
1321        app.on_acp_event(AcpEvent::ConnectionClosed);
1322        assert!(app.exit_requested(), "connection_closed should exit");
1323    }
1324
1325    #[tokio::test]
1326    async fn clear_screen_returns_clear_command() {
1327        let mut app = make_app();
1328        let mut commands = Vec::new();
1329        app.handle_conversation_messages(
1330            &mut commands,
1331            Some(vec![ConversationScreenMessage::ClearScreen]),
1332        )
1333        .await;
1334        assert!(
1335            commands
1336                .iter()
1337                .any(|c| matches!(c, RendererCommand::ClearScreen)),
1338            "should contain ClearScreen command"
1339        );
1340    }
1341
1342    #[tokio::test]
1343    async fn cancel_sends_directly_via_prompt_handle() {
1344        let mut app = make_app();
1345        app.conversation_screen.waiting_for_response = true;
1346        send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1347        assert!(!app.exit_requested());
1348    }
1349
1350    #[test]
1351    fn new_session_restores_changed_config_selections() {
1352        use acp_utils::client::PromptCommand;
1353
1354        let (mut app, mut rx) =
1355            make_app_with_config_recording(&mode_model_options("Planner", "gpt-4o"));
1356        app.update_config_options(&mode_model_options("Coder", "gpt-4o"));
1357
1358        app.on_acp_event(AcpEvent::NewSessionCreated {
1359            session_id: SessionId::new("new-session"),
1360            config_options: mode_model_options("Planner", "gpt-4o"),
1361        });
1362
1363        assert_eq!(app.session_id, SessionId::new("new-session"));
1364        assert!(app.context_usage_pct.is_none());
1365
1366        let cmd = rx.try_recv().expect("expected a SetConfigOption command");
1367        match cmd {
1368            PromptCommand::SetConfigOption {
1369                config_id, value, ..
1370            } => {
1371                assert_eq!(config_id, "mode");
1372                assert_eq!(value, "Coder");
1373            }
1374            other => panic!("expected SetConfigOption, got {other:?}"),
1375        }
1376        assert!(
1377            rx.try_recv().is_err(),
1378            "model was unchanged, no extra command expected"
1379        );
1380    }
1381}