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