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