Skip to main content

wisp/components/app/
mod.rs

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