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