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