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