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