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