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