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