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