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