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