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