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