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