1pub mod attachments;
2pub mod git_diff_mode;
3mod screen_router;
4mod view;
5
6pub use git_diff_mode::{GitDiffLoadState, GitDiffMode, GitDiffViewState, PatchFocus};
7use screen_router::ScreenRouter;
8use screen_router::ScreenRouterMessage;
9
10use crate::components::conversation_screen::ConversationScreen;
11use crate::components::conversation_screen::ConversationScreenMessage;
12use crate::keybindings::Keybindings;
13use crate::settings;
14use crate::settings::overlay::{SettingsMessage, SettingsOverlay};
15use acp_utils::client::{AcpEvent, AcpPromptHandle};
16use acp_utils::config_meta::SelectOptionMeta;
17use acp_utils::config_option_id::ConfigOptionId;
18use agent_client_protocol::{self as acp, SessionId};
19use attachments::build_attachment_blocks;
20use std::path::PathBuf;
21use std::time::Instant;
22use tokio::sync::oneshot;
23use tui::RendererCommand;
24use tui::{Component, Event, Frame, KeyEvent, ViewContext};
25
26#[derive(Debug, Clone)]
27pub struct PromptAttachment {
28 pub path: PathBuf,
29 pub display_name: String,
30}
31
32pub struct App {
33 agent_name: String,
34 context_usage_pct: Option<u8>,
35 exit_requested: bool,
36 conversation_screen: ConversationScreen,
37 prompt_capabilities: acp::PromptCapabilities,
38 config_options: Vec<acp::SessionConfigOption>,
39 server_statuses: Vec<acp_utils::notifications::McpServerStatusEntry>,
40 auth_methods: Vec<acp::AuthMethod>,
41 settings_overlay: Option<SettingsOverlay>,
42 screen_router: ScreenRouter,
43 keybindings: Keybindings,
44 session_id: SessionId,
45 prompt_handle: AcpPromptHandle,
46 working_dir: PathBuf,
47}
48
49impl App {
50 pub fn new(
51 session_id: SessionId,
52 agent_name: String,
53 prompt_capabilities: acp::PromptCapabilities,
54 config_options: &[acp::SessionConfigOption],
55 auth_methods: Vec<acp::AuthMethod>,
56 working_dir: PathBuf,
57 prompt_handle: AcpPromptHandle,
58 ) -> Self {
59 let keybindings = Keybindings::default();
60 Self {
61 agent_name,
62 context_usage_pct: None,
63 exit_requested: false,
64 conversation_screen: ConversationScreen::new(keybindings.clone()),
65 prompt_capabilities,
66 config_options: config_options.to_vec(),
67 server_statuses: Vec::new(),
68 auth_methods,
69 settings_overlay: None,
70 screen_router: ScreenRouter::new(GitDiffMode::new(working_dir.clone())),
71 keybindings,
72 session_id,
73 prompt_handle,
74 working_dir,
75 }
76 }
77
78 pub fn exit_requested(&self) -> bool {
79 self.exit_requested
80 }
81
82 pub fn has_settings_overlay(&self) -> bool {
83 self.settings_overlay.is_some()
84 }
85
86 pub fn needs_mouse_capture(&self) -> bool {
87 self.settings_overlay.is_some() || self.screen_router.is_git_diff()
88 }
89
90 pub fn wants_tick(&self) -> bool {
91 self.conversation_screen.wants_tick()
92 }
93
94 fn git_diff_mode_mut(&mut self) -> &mut GitDiffMode {
95 self.screen_router.git_diff_mode_mut()
96 }
97
98 pub fn on_acp_event(&mut self, event: AcpEvent) {
99 match event {
100 AcpEvent::SessionUpdate(update) => self.on_session_update(&update),
101 AcpEvent::ExtNotification(notification) => {
102 self.on_ext_notification(¬ification);
103 }
104 AcpEvent::PromptDone(stop_reason) => self.on_prompt_done(stop_reason),
105 AcpEvent::PromptError(error) => {
106 self.conversation_screen.on_prompt_error(&error);
107 }
108 AcpEvent::ElicitationRequest {
109 params,
110 response_tx,
111 } => self.on_elicitation_request(params, response_tx),
112 AcpEvent::AuthenticateComplete { method_id } => {
113 self.on_authenticate_complete(&method_id);
114 }
115 AcpEvent::AuthenticateFailed { method_id, error } => {
116 self.on_authenticate_failed(&method_id, &error);
117 }
118 AcpEvent::SessionsListed { sessions } => {
119 let current_id = &self.session_id;
120 let filtered: Vec<_> = sessions
121 .into_iter()
122 .filter(|s| s.session_id != *current_id)
123 .collect();
124 self.conversation_screen.open_session_picker(filtered);
125 }
126 AcpEvent::SessionLoaded {
130 session_id,
131 config_options,
132 } => {
133 self.session_id = session_id;
134 self.update_config_options(&config_options);
135 }
136 AcpEvent::NewSessionCreated {
137 session_id,
138 config_options,
139 } => {
140 let previous_selections = current_config_selections(&self.config_options);
141 self.session_id = session_id;
142 self.update_config_options(&config_options);
143 self.context_usage_pct = None;
144 self.restore_config_selections(&previous_selections);
145 }
146 AcpEvent::ConnectionClosed => {
147 self.exit_requested = true;
148 }
149 }
150 }
151
152 async fn handle_key(&mut self, commands: &mut Vec<RendererCommand>, key_event: KeyEvent) {
153 if self.keybindings.exit.matches(key_event) {
154 self.exit_requested = true;
155 return;
156 }
157
158 if self.keybindings.toggle_git_diff.matches(key_event)
159 && !self.conversation_screen.has_modal()
160 {
161 if let Some(msg) = self.screen_router.toggle_git_diff() {
162 self.handle_screen_router_message(commands, msg).await;
163 }
164 return;
165 }
166
167 let event = Event::Key(key_event);
168
169 if self.screen_router.is_git_diff() {
170 for msg in self
171 .screen_router
172 .on_event(&event)
173 .await
174 .unwrap_or_default()
175 {
176 self.handle_screen_router_message(commands, msg).await;
177 }
178 } else if self.settings_overlay.is_some() {
179 self.handle_settings_overlay_event(commands, &event).await;
180 } else {
181 let outcome = self.conversation_screen.on_event(&event).await;
182 let consumed = outcome.is_some();
183 self.handle_conversation_messages(commands, outcome).await;
184 if !consumed {
185 self.handle_fallthrough_keybindings(key_event);
186 }
187 }
188 }
189
190 async fn submit_prompt(&mut self, user_input: String, attachments: Vec<PromptAttachment>) {
191 let outcome = build_attachment_blocks(&attachments).await;
192 self.conversation_screen.conversation.push_user_message("");
193 self.conversation_screen
194 .conversation
195 .push_user_message(&user_input);
196 for placeholder in &outcome.transcript_placeholders {
197 self.conversation_screen
198 .conversation
199 .push_user_message(placeholder);
200 }
201 for w in outcome.warnings {
202 self.conversation_screen
203 .conversation
204 .push_user_message(&format!("[wisp] {w}"));
205 }
206
207 if let Some(message) = self.media_support_error(&outcome.blocks) {
208 self.conversation_screen.reject_local_prompt(&message);
209 return;
210 }
211
212 let _ = self.prompt_handle.prompt(
213 &self.session_id,
214 &user_input,
215 if outcome.blocks.is_empty() {
216 None
217 } else {
218 Some(outcome.blocks)
219 },
220 );
221 }
222
223 async fn handle_conversation_messages(
224 &mut self,
225 commands: &mut Vec<RendererCommand>,
226 outcome: Option<Vec<ConversationScreenMessage>>,
227 ) {
228 for msg in outcome.unwrap_or_default() {
229 match msg {
230 ConversationScreenMessage::SendPrompt {
231 user_input,
232 attachments,
233 } => {
234 self.conversation_screen.waiting_for_response = true;
235 self.submit_prompt(user_input, attachments).await;
236 }
237 ConversationScreenMessage::ClearScreen => {
238 commands.push(RendererCommand::ClearScreen);
239 }
240 ConversationScreenMessage::NewSession => {
241 commands.push(RendererCommand::ClearScreen);
242 let _ = self.prompt_handle.new_session(&self.working_dir);
243 }
244 ConversationScreenMessage::OpenSettings => {
245 self.open_settings_overlay();
246 }
247 ConversationScreenMessage::OpenSessionPicker => {
248 let _ = self.prompt_handle.list_sessions();
249 }
250 ConversationScreenMessage::LoadSession { session_id, cwd } => {
251 if let Err(e) = self.prompt_handle.load_session(&session_id, &cwd) {
252 tracing::warn!("Failed to load session: {e}");
253 }
254 }
255 }
256 }
257 }
258
259 fn handle_fallthrough_keybindings(&self, key_event: KeyEvent) {
260 if self.keybindings.cycle_reasoning.matches(key_event) {
261 if let Some((id, val)) = settings::cycle_reasoning_option(&self.config_options) {
262 let _ = self
263 .prompt_handle
264 .set_config_option(&self.session_id, &id, &val);
265 }
266 return;
267 }
268
269 if self.keybindings.cycle_mode.matches(key_event) {
270 if let Some((id, val)) = settings::cycle_quick_option(&self.config_options) {
271 let _ = self
272 .prompt_handle
273 .set_config_option(&self.session_id, &id, &val);
274 }
275 return;
276 }
277
278 if self.keybindings.cancel.matches(key_event)
279 && self.conversation_screen.is_waiting()
280 && let Err(e) = self.prompt_handle.cancel(&self.session_id)
281 {
282 tracing::warn!("Failed to send cancel: {e}");
283 }
284 }
285
286 async fn handle_settings_overlay_event(
287 &mut self,
288 commands: &mut Vec<RendererCommand>,
289 event: &Event,
290 ) {
291 let Some(ref mut overlay) = self.settings_overlay else {
292 return;
293 };
294 let messages = overlay.on_event(event).await.unwrap_or_default();
295
296 for msg in messages {
297 match msg {
298 SettingsMessage::Close => {
299 self.settings_overlay = None;
300 return;
301 }
302 SettingsMessage::SetConfigOption { config_id, value } => {
303 let _ =
304 self.prompt_handle
305 .set_config_option(&self.session_id, &config_id, &value);
306 }
307 SettingsMessage::SetTheme(theme) => {
308 commands.push(RendererCommand::SetTheme(theme));
309 }
310 SettingsMessage::AuthenticateServer(name) => {
311 let _ = self
312 .prompt_handle
313 .authenticate_mcp_server(&self.session_id, &name);
314 }
315 SettingsMessage::AuthenticateProvider(ref method_id) => {
316 if let Some(ref mut overlay) = self.settings_overlay {
317 overlay.on_authenticate_started(method_id);
318 }
319 let _ = self.prompt_handle.authenticate(&self.session_id, method_id);
320 }
321 }
322 }
323 }
324
325 fn open_settings_overlay(&mut self) {
326 self.settings_overlay = Some(settings::create_overlay(
327 &self.config_options,
328 &self.server_statuses,
329 &self.auth_methods,
330 ));
331 }
332
333 fn update_config_options(&mut self, config_options: &[acp::SessionConfigOption]) {
334 self.config_options = config_options.to_vec();
335 if let Some(ref mut overlay) = self.settings_overlay {
336 overlay.update_config_options(config_options);
337 }
338 }
339
340 fn update_auth_methods(&mut self, auth_methods: Vec<acp::AuthMethod>) {
341 self.auth_methods = auth_methods;
342 if let Some(ref mut overlay) = self.settings_overlay {
343 overlay.update_auth_methods(self.auth_methods.clone());
344 }
345 }
346
347 fn restore_config_selections(&self, previous: &[(String, String)]) {
348 let new_selections = current_config_selections(&self.config_options);
349 for (id, old_value) in previous {
350 let still_exists = new_selections.iter().any(|(new_id, _)| new_id == id);
351 if !still_exists {
352 tracing::debug!(
353 config_id = id,
354 "config option no longer present in new session"
355 );
356 continue;
357 }
358 let server_reset = new_selections
359 .iter()
360 .any(|(new_id, new_val)| new_id == id && new_val != old_value);
361 if server_reset
362 && let Err(e) =
363 self.prompt_handle
364 .set_config_option(&self.session_id, id, old_value)
365 {
366 tracing::warn!(config_id = id, error = %e, "failed to restore config option");
367 }
368 }
369 }
370
371 async fn handle_screen_router_message(
372 &mut self,
373 commands: &mut Vec<RendererCommand>,
374 msg: ScreenRouterMessage,
375 ) {
376 match msg {
377 ScreenRouterMessage::LoadGitDiff | ScreenRouterMessage::RefreshGitDiff => {
378 self.git_diff_mode_mut().complete_load().await;
379 }
380 ScreenRouterMessage::SendPrompt { user_input } => {
381 if self.conversation_screen.is_waiting() {
382 return;
383 }
384
385 self.conversation_screen.waiting_for_response = true;
386 self.submit_prompt(user_input, Vec::new()).await;
387 self.screen_router.close_git_diff();
388 }
389 }
390 let _ = commands;
391 }
392
393 fn on_session_update(&mut self, update: &acp::SessionUpdate) {
394 self.conversation_screen.on_session_update(update);
395
396 if let acp::SessionUpdate::ConfigOptionUpdate(config_update) = update {
397 self.update_config_options(&config_update.config_options);
398 }
399 }
400
401 fn on_prompt_done(&mut self, stop_reason: acp::StopReason) {
402 self.conversation_screen.on_prompt_done(stop_reason);
403 }
404
405 fn on_elicitation_request(
406 &mut self,
407 params: acp_utils::notifications::ElicitationParams,
408 response_tx: oneshot::Sender<acp_utils::notifications::ElicitationResponse>,
409 ) {
410 self.settings_overlay = None;
411 self.conversation_screen
412 .on_elicitation_request(params, response_tx);
413 }
414
415 fn on_ext_notification(&mut self, notification: &acp::ExtNotification) {
416 use acp_utils::notifications::{
417 AUTH_METHODS_UPDATED_METHOD, AuthMethodsUpdatedParams, CONTEXT_CLEARED_METHOD,
418 CONTEXT_USAGE_METHOD, ContextUsageParams, McpNotification, SUB_AGENT_PROGRESS_METHOD,
419 SubAgentProgressParams,
420 };
421
422 match notification.method.as_ref() {
423 CONTEXT_CLEARED_METHOD => {
424 self.conversation_screen.reset_after_context_cleared();
425 self.context_usage_pct = None;
426 }
427 CONTEXT_USAGE_METHOD => {
428 if let Ok(params) =
429 serde_json::from_str::<ContextUsageParams>(notification.params.get())
430 {
431 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
432 {
433 self.context_usage_pct = params.usage_ratio.map(|usage_ratio| {
434 ((1.0 - usage_ratio) * 100.0).clamp(0.0, 100.0).round() as u8
435 });
436 }
437 }
438 }
439 SUB_AGENT_PROGRESS_METHOD => {
440 if let Ok(progress) =
441 serde_json::from_str::<SubAgentProgressParams>(notification.params.get())
442 {
443 self.conversation_screen.on_sub_agent_progress(&progress);
444 }
445 }
446 AUTH_METHODS_UPDATED_METHOD => {
447 if let Ok(params) = AuthMethodsUpdatedParams::try_from(notification) {
448 self.update_auth_methods(params.auth_methods);
449 }
450 }
451 _ => {
452 if let Ok(McpNotification::ServerStatus { servers }) =
453 McpNotification::try_from(notification)
454 {
455 if let Some(ref mut overlay) = self.settings_overlay {
456 overlay.update_server_statuses(servers.clone());
457 }
458 self.server_statuses = servers;
459 }
460 }
461 }
462 }
463
464 fn on_authenticate_complete(&mut self, method_id: &str) {
465 if let Some(ref mut overlay) = self.settings_overlay {
466 overlay.on_authenticate_complete(method_id);
467 }
468 }
469
470 fn on_authenticate_failed(&mut self, method_id: &str, error: &str) {
471 tracing::warn!("Provider auth failed for {method_id}: {error}");
472 if let Some(ref mut overlay) = self.settings_overlay {
473 overlay.on_authenticate_failed(method_id);
474 }
475 }
476
477 fn media_support_error(&self, blocks: &[acp::ContentBlock]) -> Option<String> {
478 let requires_image = blocks
479 .iter()
480 .any(|block| matches!(block, acp::ContentBlock::Image(_)));
481 let requires_audio = blocks
482 .iter()
483 .any(|block| matches!(block, acp::ContentBlock::Audio(_)));
484
485 if !requires_image && !requires_audio {
486 return None;
487 }
488
489 if requires_image && !self.prompt_capabilities.image {
490 return Some("ACP agent does not support image input.".to_string());
491 }
492 if requires_audio && !self.prompt_capabilities.audio {
493 return Some("ACP agent does not support audio input.".to_string());
494 }
495
496 let option = self
497 .config_options
498 .iter()
499 .find(|option| option.id.0.as_ref() == ConfigOptionId::Model.as_str())?;
500 let acp::SessionConfigKind::Select(select) = &option.kind else {
501 return None;
502 };
503
504 let values: Vec<_> = select
505 .current_value
506 .0
507 .split(',')
508 .map(str::trim)
509 .filter(|value| !value.is_empty())
510 .collect();
511
512 if values.is_empty() {
513 return None;
514 }
515
516 let acp::SessionConfigSelectOptions::Ungrouped(options) = &select.options else {
517 return None;
518 };
519
520 let selected_meta: Vec<_> = values
521 .iter()
522 .filter_map(|value| {
523 options
524 .iter()
525 .find(|option| option.value.0.as_ref() == *value)
526 .map(|option| SelectOptionMeta::from_meta(option.meta.as_ref()))
527 })
528 .collect();
529
530 if selected_meta.len() != values.len() {
531 return Some("Current model selection is missing prompt capability metadata.".into());
532 }
533
534 if requires_image && selected_meta.iter().any(|meta| !meta.supports_image) {
535 return Some("Current model selection does not support image input.".to_string());
536 }
537 if requires_audio && selected_meta.iter().any(|meta| !meta.supports_audio) {
538 return Some("Current model selection does not support audio input.".to_string());
539 }
540
541 None
542 }
543}
544
545impl Component for App {
546 type Message = RendererCommand;
547
548 async fn on_event(&mut self, event: &Event) -> Option<Vec<RendererCommand>> {
549 let mut commands = Vec::new();
550 match event {
551 Event::Key(key_event) => self.handle_key(&mut commands, *key_event).await,
552 Event::Paste(_) => {
553 self.settings_overlay = None;
554 let outcome = self.conversation_screen.on_event(event).await;
555 self.handle_conversation_messages(&mut commands, outcome)
556 .await;
557 }
558 Event::Tick => {
559 let now = Instant::now();
560 self.conversation_screen.on_tick(now);
561 }
562 Event::Mouse(_) => {
563 if self.screen_router.is_git_diff() {
564 for msg in self.screen_router.on_event(event).await.unwrap_or_default() {
565 self.handle_screen_router_message(&mut commands, msg).await;
566 }
567 } else if self.settings_overlay.is_some() {
568 self.handle_settings_overlay_event(&mut commands, event)
569 .await;
570 }
571 }
572 Event::Resize(_) => {}
573 }
574 Some(commands)
575 }
576
577 fn render(&mut self, ctx: &ViewContext) -> Frame {
578 self.conversation_screen.refresh_caches(ctx);
579 self.screen_router.refresh_caches(ctx);
580
581 let height = (ctx.size.height.saturating_sub(1)) as usize;
582 if let Some(ref mut overlay) = self.settings_overlay
583 && height >= 3
584 {
585 overlay.update_child_viewport(height.saturating_sub(4));
586 }
587
588 view::build_frame(self, ctx)
589 }
590}
591
592fn current_config_selections(options: &[acp::SessionConfigOption]) -> Vec<(String, String)> {
593 options
594 .iter()
595 .filter_map(|opt| {
596 let acp::SessionConfigKind::Select(ref select) = opt.kind else {
597 return None;
598 };
599 Some((opt.id.0.to_string(), select.current_value.0.to_string()))
600 })
601 .collect()
602}
603
604#[cfg(test)]
605pub(crate) mod test_helpers {
606 use super::*;
607
608 pub fn make_app() -> App {
609 App::new(
610 SessionId::new("test"),
611 "test-agent".to_string(),
612 acp::PromptCapabilities::new(),
613 &[],
614 vec![],
615 PathBuf::from("."),
616 AcpPromptHandle::noop(),
617 )
618 }
619
620 pub fn make_app_with_config(config_options: &[acp::SessionConfigOption]) -> App {
621 App::new(
622 SessionId::new("test"),
623 "test-agent".to_string(),
624 acp::PromptCapabilities::new(),
625 config_options,
626 vec![],
627 PathBuf::from("."),
628 AcpPromptHandle::noop(),
629 )
630 }
631
632 pub fn make_app_with_auth(auth_methods: Vec<acp::AuthMethod>) -> App {
633 App::new(
634 SessionId::new("test"),
635 "test-agent".to_string(),
636 acp::PromptCapabilities::new(),
637 &[],
638 auth_methods,
639 PathBuf::from("."),
640 AcpPromptHandle::noop(),
641 )
642 }
643
644 pub fn make_app_with_config_recording(
645 config_options: &[acp::SessionConfigOption],
646 ) -> (
647 App,
648 tokio::sync::mpsc::UnboundedReceiver<acp_utils::client::PromptCommand>,
649 ) {
650 let (handle, rx) = AcpPromptHandle::recording();
651 let app = App::new(
652 SessionId::new("test"),
653 "test-agent".to_string(),
654 acp::PromptCapabilities::new(),
655 config_options,
656 vec![],
657 PathBuf::from("."),
658 handle,
659 );
660 (app, rx)
661 }
662
663 pub fn make_app_with_session_id(session_id: &str) -> App {
664 App::new(
665 SessionId::new(session_id),
666 "test-agent".to_string(),
667 acp::PromptCapabilities::new(),
668 &[],
669 vec![],
670 PathBuf::from("."),
671 AcpPromptHandle::noop(),
672 )
673 }
674
675 pub fn make_app_with_config_and_capabilities_recording(
676 config_options: &[acp::SessionConfigOption],
677 prompt_capabilities: acp::PromptCapabilities,
678 ) -> (
679 App,
680 tokio::sync::mpsc::UnboundedReceiver<acp_utils::client::PromptCommand>,
681 ) {
682 let (handle, rx) = AcpPromptHandle::recording();
683 let app = App::new(
684 SessionId::new("test"),
685 "test-agent".to_string(),
686 prompt_capabilities,
687 config_options,
688 vec![],
689 PathBuf::from("."),
690 handle,
691 );
692 (app, rx)
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::test_helpers::*;
699 use super::*;
700 use crate::components::command_picker::CommandEntry;
701 use crate::components::elicitation_form::ElicitationForm;
702 use crate::settings::{ThemeSettings as WispThemeSettings, WispSettings, save_settings};
703 use crate::test_helpers::with_wisp_home;
704 use std::fs;
705 use std::time::Duration;
706 use tempfile::TempDir;
707 use tui::testing::render_component;
708 use tui::{Frame, KeyCode, KeyModifiers, Renderer, Theme, ViewContext};
709
710 fn make_renderer() -> Renderer<Vec<u8>> {
711 Renderer::new(Vec::new(), Theme::default(), (80, 24))
712 }
713
714 fn render_app(renderer: &mut Renderer<Vec<u8>>, app: &mut App, context: &ViewContext) -> Frame {
715 renderer.render_frame(|ctx| app.render(ctx)).unwrap();
716 app.render(context)
717 }
718
719 fn frame_contains(output: &Frame, text: &str) -> bool {
720 output
721 .lines()
722 .iter()
723 .any(|line| line.plain_text().contains(text))
724 }
725
726 async fn send_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
727 app.on_event(&Event::Key(KeyEvent::new(code, modifiers)))
728 .await;
729 }
730
731 fn setup_themes_dir(files: &[&str]) -> TempDir {
732 let temp_dir = TempDir::new().unwrap();
733 let themes_dir = temp_dir.path().join("themes");
734 fs::create_dir_all(&themes_dir).unwrap();
735 for f in files {
736 fs::write(themes_dir.join(f), "x").unwrap();
737 }
738 temp_dir
739 }
740
741 fn make_plan_entry(name: &str, status: acp::PlanEntryStatus) -> acp::PlanEntry {
742 acp::PlanEntry::new(name, acp::PlanEntryPriority::Medium, status)
743 }
744
745 fn mode_model_options(
746 mode_val: impl Into<String>,
747 model_val: impl Into<String>,
748 ) -> Vec<acp::SessionConfigOption> {
749 vec![
750 acp::SessionConfigOption::select(
751 "mode",
752 "Mode",
753 mode_val.into(),
754 vec![
755 acp::SessionConfigSelectOption::new("Planner", "Planner"),
756 acp::SessionConfigSelectOption::new("Coder", "Coder"),
757 ],
758 )
759 .category(acp::SessionConfigOptionCategory::Mode),
760 acp::SessionConfigOption::select(
761 "model",
762 "Model",
763 model_val.into(),
764 vec![
765 acp::SessionConfigSelectOption::new("gpt-4o", "GPT-4o"),
766 acp::SessionConfigSelectOption::new("claude", "Claude"),
767 ],
768 )
769 .category(acp::SessionConfigOptionCategory::Model),
770 ]
771 }
772
773 fn image_model_options() -> Vec<acp::SessionConfigOption> {
774 vec![
775 acp::SessionConfigOption::select(
776 "model",
777 "Model",
778 "anthropic:claude-sonnet-4-5",
779 vec![
780 acp::SessionConfigSelectOption::new(
781 "anthropic:claude-sonnet-4-5",
782 "Claude Sonnet",
783 )
784 .meta(
785 SelectOptionMeta {
786 reasoning_levels: vec![],
787 supports_image: true,
788 supports_audio: false,
789 }
790 .into_meta(),
791 ),
792 acp::SessionConfigSelectOption::new("deepseek:deepseek-chat", "DeepSeek").meta(
793 SelectOptionMeta {
794 reasoning_levels: vec![],
795 supports_image: false,
796 supports_audio: false,
797 }
798 .into_meta(),
799 ),
800 ],
801 )
802 .category(acp::SessionConfigOptionCategory::Model),
803 ]
804 }
805
806 #[test]
807 fn settings_overlay_with_themes() {
808 let temp_dir = setup_themes_dir(&["catppuccin.tmTheme"]);
809 with_wisp_home(temp_dir.path(), || {
810 let mut app = make_app();
811 app.open_settings_overlay();
812 assert!(app.settings_overlay.is_some());
813 });
814
815 let temp_dir = setup_themes_dir(&["catppuccin.tmTheme", "nord.tmTheme"]);
816 with_wisp_home(temp_dir.path(), || {
817 let settings = WispSettings {
818 theme: WispThemeSettings {
819 file: Some("nord.tmTheme".to_string()),
820 },
821 };
822 save_settings(&settings).unwrap();
823 let mut app = make_app();
824 app.open_settings_overlay();
825 assert!(app.settings_overlay.is_some());
826 });
827 }
828
829 #[test]
830 fn command_picker_cursor_stays_in_input_prompt() {
831 let mut app = make_app();
832 let mut renderer = make_renderer();
833 app.conversation_screen
834 .prompt_composer
835 .open_command_picker_with_entries(vec![CommandEntry {
836 name: "settings".to_string(),
837 description: "Open settings".to_string(),
838 has_input: false,
839 hint: None,
840 builtin: true,
841 }]);
842
843 let context = ViewContext::new((120, 40));
844 let output = render_app(&mut renderer, &mut app, &context);
845 let input_row = output
846 .lines()
847 .iter()
848 .position(|line| line.plain_text().contains("> "))
849 .expect("input prompt should exist");
850 assert_eq!(output.cursor().row, input_row);
851 }
852
853 #[test]
854 fn settings_overlay_replaces_conversation_window() {
855 let options = vec![acp::SessionConfigOption::select(
856 "model",
857 "Model",
858 "m1",
859 vec![acp::SessionConfigSelectOption::new("m1", "M1")],
860 )];
861 let mut app = make_app_with_config(&options);
862 let mut renderer = make_renderer();
863 app.open_settings_overlay();
864
865 let ctx = ViewContext::new((120, 40));
866 assert!(frame_contains(
867 &render_app(&mut renderer, &mut app, &ctx),
868 "Configuration"
869 ));
870 app.settings_overlay = None;
871 assert!(!frame_contains(
872 &render_app(&mut renderer, &mut app, &ctx),
873 "Configuration"
874 ));
875 }
876
877 #[test]
878 fn extract_model_display_handles_comma_separated_value() {
879 use crate::components::status_line::extract_model_display;
880 let options = vec![acp::SessionConfigOption::select(
881 "model",
882 "Model",
883 "a:x,b:y",
884 vec![
885 acp::SessionConfigSelectOption::new("a:x", "Alpha / X"),
886 acp::SessionConfigSelectOption::new("b:y", "Beta / Y"),
887 acp::SessionConfigSelectOption::new("c:z", "Gamma / Z"),
888 ],
889 )];
890 assert_eq!(
891 extract_model_display(&options).as_deref(),
892 Some("Alpha / X + Beta / Y")
893 );
894 }
895
896 #[test]
897 fn extract_reasoning_effort_returns_none_for_none_value() {
898 use crate::components::status_line::extract_reasoning_effort;
899 use acp_utils::config_option_id::ConfigOptionId;
900 let options = vec![acp::SessionConfigOption::select(
901 ConfigOptionId::ReasoningEffort.as_str(),
902 "Reasoning",
903 "none",
904 vec![
905 acp::SessionConfigSelectOption::new("none", "None"),
906 acp::SessionConfigSelectOption::new("low", "Low"),
907 ],
908 )];
909 assert_eq!(extract_reasoning_effort(&options), None);
910 }
911
912 #[test]
913 fn render_hides_plan_header_when_no_entries_are_visible() {
914 let mut app = make_app();
915 let mut renderer = make_renderer();
916 let grace_period = app.conversation_screen.plan_tracker.grace_period;
917 app.conversation_screen.plan_tracker.replace(
918 vec![make_plan_entry("1", acp::PlanEntryStatus::Completed)],
919 Instant::now()
920 .checked_sub(grace_period + Duration::from_millis(1))
921 .unwrap(),
922 );
923 app.conversation_screen.plan_tracker.on_tick(Instant::now());
924
925 let output = render_app(&mut renderer, &mut app, &ViewContext::new((120, 40)));
926 assert!(!frame_contains(&output, "Plan"));
927 }
928
929 #[test]
930 fn plan_version_increments_on_replace_and_clear() {
931 let mut app = make_app();
932 let v0 = app.conversation_screen.plan_tracker.version();
933
934 app.conversation_screen.plan_tracker.replace(
935 vec![make_plan_entry("Task A", acp::PlanEntryStatus::Pending)],
936 Instant::now(),
937 );
938 let v1 = app.conversation_screen.plan_tracker.version();
939 assert!(v1 > v0, "replace should increment version");
940
941 app.conversation_screen.plan_tracker.clear();
942 assert!(
943 app.conversation_screen.plan_tracker.version() > v1,
944 "clear should increment version"
945 );
946 }
947
948 #[test]
949 fn sessions_listed_filters_out_current_session() {
950 let mut app = make_app_with_session_id("current-session");
951 app.on_acp_event(AcpEvent::SessionsListed {
952 sessions: vec![
953 acp::SessionInfo::new("other-session-1", PathBuf::from("/project"))
954 .title("First other session".to_string()),
955 acp::SessionInfo::new("current-session", PathBuf::from("/project"))
956 .title("Current session title".to_string()),
957 acp::SessionInfo::new("other-session-2", PathBuf::from("/other"))
958 .title("Second other session".to_string()),
959 ],
960 });
961
962 let picker = match &mut app.conversation_screen.active_modal {
963 Some(crate::components::conversation_screen::Modal::SessionPicker(p)) => p,
964 _ => panic!("expected session picker modal"),
965 };
966 let lines = render_component(|ctx| picker.render(ctx), 60, 10).get_lines();
967
968 let has = |text: &str| lines.iter().any(|l| l.contains(text));
969 assert!(
970 !has("Current session title"),
971 "current session should be filtered out"
972 );
973 assert!(
974 has("First other session"),
975 "first other session should be present"
976 );
977 assert!(
978 has("Second other session"),
979 "second other session should be present"
980 );
981 }
982
983 #[tokio::test]
984 async fn custom_exit_keybinding_triggers_exit() {
985 use crate::keybindings::KeyBinding;
986 let mut app = make_app();
987 app.keybindings.exit = KeyBinding::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
988
989 send_key(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL).await;
990 assert!(
991 !app.exit_requested(),
992 "default Ctrl+C should no longer exit"
993 );
994
995 send_key(&mut app, KeyCode::Char('q'), KeyModifiers::CONTROL).await;
996 assert!(app.exit_requested(), "custom Ctrl+Q should exit");
997 }
998
999 #[tokio::test]
1000 async fn ctrl_g_toggles_git_diff_viewer() {
1001 let mut app = make_app();
1002
1003 send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1004 assert!(app.screen_router.is_git_diff(), "should open git diff");
1005
1006 send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1007 assert!(!app.screen_router.is_git_diff(), "should close git diff");
1008 }
1009
1010 #[tokio::test]
1011 async fn needs_mouse_capture_in_git_diff() {
1012 let mut app = make_app();
1013 assert!(!app.needs_mouse_capture());
1014
1015 send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1016 assert!(app.needs_mouse_capture());
1017
1018 send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1019 assert!(!app.needs_mouse_capture());
1020 }
1021
1022 #[tokio::test]
1023 async fn ctrl_g_blocked_during_elicitation() {
1024 let mut app = make_app();
1025 app.conversation_screen.active_modal =
1026 Some(crate::components::conversation_screen::Modal::Elicitation(
1027 ElicitationForm::from_params(
1028 acp_utils::notifications::ElicitationParams {
1029 message: "test".to_string(),
1030 schema: acp_utils::ElicitationSchema::builder().build().unwrap(),
1031 },
1032 tokio::sync::oneshot::channel().0,
1033 ),
1034 ));
1035
1036 send_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL).await;
1037 assert!(
1038 !app.screen_router.is_git_diff(),
1039 "git diff should not open during elicitation"
1040 );
1041 }
1042
1043 #[tokio::test]
1044 async fn esc_in_diff_mode_does_not_cancel() {
1045 let mut app = make_app();
1046 app.conversation_screen.waiting_for_response = true;
1047 app.screen_router.enter_git_diff_for_test();
1048
1049 send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1050
1051 assert!(!app.exit_requested());
1052 assert!(
1053 app.conversation_screen.waiting_for_response,
1054 "Esc should NOT cancel a running prompt while git diff mode is active"
1055 );
1056 }
1057
1058 #[tokio::test]
1059 async fn git_diff_submit_sends_prompt_and_closes_diff_when_idle() {
1060 use acp_utils::client::PromptCommand;
1061
1062 let (mut app, mut rx) = make_app_with_config_recording(&[]);
1063 app.screen_router.enter_git_diff_for_test();
1064
1065 let mut commands = Vec::new();
1066 app.handle_screen_router_message(
1067 &mut commands,
1068 ScreenRouterMessage::SendPrompt {
1069 user_input: "Looks good".to_string(),
1070 },
1071 )
1072 .await;
1073
1074 assert!(
1075 !app.screen_router.is_git_diff(),
1076 "successful submit should exit git diff mode"
1077 );
1078 assert!(
1079 app.conversation_screen.waiting_for_response,
1080 "submit should transition into waiting state"
1081 );
1082
1083 let cmd = rx.try_recv().expect("expected Prompt command to be sent");
1084 match cmd {
1085 PromptCommand::Prompt { text, .. } => {
1086 assert!(text.contains("Looks good"));
1087 }
1088 other => panic!("expected Prompt command, got {other:?}"),
1089 }
1090 }
1091
1092 #[tokio::test]
1093 async fn git_diff_submit_while_waiting_is_ignored_and_keeps_diff_open() {
1094 let (mut app, mut rx) = make_app_with_config_recording(&[]);
1095 app.conversation_screen.waiting_for_response = true;
1096 app.screen_router.enter_git_diff_for_test();
1097
1098 let mut commands = Vec::new();
1099 app.handle_screen_router_message(
1100 &mut commands,
1101 ScreenRouterMessage::SendPrompt {
1102 user_input: "Needs follow-up".to_string(),
1103 },
1104 )
1105 .await;
1106
1107 assert!(
1108 app.screen_router.is_git_diff(),
1109 "blocked submit should keep git diff mode open"
1110 );
1111 assert!(
1112 rx.try_recv().is_err(),
1113 "no prompt should be sent while waiting"
1114 );
1115 }
1116
1117 #[tokio::test]
1118 async fn mouse_scroll_ignored_in_conversation_mode() {
1119 use tui::{MouseEvent, MouseEventKind};
1120 let mut app = make_app();
1121 let mouse = MouseEvent {
1122 kind: MouseEventKind::ScrollDown,
1123 column: 0,
1124 row: 0,
1125 modifiers: KeyModifiers::NONE,
1126 };
1127 app.on_event(&Event::Mouse(mouse)).await;
1128 }
1129
1130 #[tokio::test]
1131 async fn prompt_composer_submit_pushes_echo_lines() {
1132 use crate::components::conversation_window::SegmentContent;
1133 let mut app = make_app();
1134 let mut commands = Vec::new();
1135 app.handle_conversation_messages(
1136 &mut commands,
1137 Some(vec![ConversationScreenMessage::SendPrompt {
1138 user_input: "hello".to_string(),
1139 attachments: vec![],
1140 }]),
1141 )
1142 .await;
1143
1144 let has_hello = app
1145 .conversation_screen
1146 .conversation
1147 .segments()
1148 .any(|seg| matches!(seg, SegmentContent::UserMessage(text) if text == "hello"));
1149 assert!(
1150 has_hello,
1151 "conversation buffer should contain the user input"
1152 );
1153 }
1154
1155 #[tokio::test]
1156 async fn unsupported_media_is_blocked_locally() {
1157 let (mut app, mut rx) = make_app_with_config_and_capabilities_recording(
1158 &image_model_options(),
1159 acp::PromptCapabilities::new().image(true).audio(false),
1160 );
1161 let mut commands = Vec::new();
1162 let temp = tempfile::tempdir().unwrap();
1163 let audio_path = temp.path().join("clip.wav");
1164 std::fs::write(&audio_path, b"fake wav").unwrap();
1165
1166 app.handle_conversation_messages(
1167 &mut commands,
1168 Some(vec![ConversationScreenMessage::SendPrompt {
1169 user_input: "listen".to_string(),
1170 attachments: vec![PromptAttachment {
1171 path: audio_path,
1172 display_name: "clip.wav".to_string(),
1173 }],
1174 }]),
1175 )
1176 .await;
1177
1178 assert!(rx.try_recv().is_err(), "prompt should be blocked locally");
1179 assert!(!app.conversation_screen.waiting_for_response);
1180 let messages: Vec<_> = app
1181 .conversation_screen
1182 .conversation
1183 .segments()
1184 .filter_map(|segment| match segment {
1185 crate::components::conversation_window::SegmentContent::UserMessage(text) => {
1186 Some(text.clone())
1187 }
1188 _ => None,
1189 })
1190 .collect();
1191 assert!(messages.iter().any(|text| text == "listen"));
1192 assert!(
1193 messages
1194 .iter()
1195 .any(|text| text == "[audio attachment: clip.wav]")
1196 );
1197 assert!(messages.iter().any(|text| {
1198 text == "[wisp] ACP agent does not support audio input."
1199 || text == "[wisp] Current model selection does not support audio input."
1200 }));
1201 }
1202
1203 #[test]
1204 fn replayed_media_user_chunks_render_placeholders() {
1205 use crate::components::conversation_window::SegmentContent;
1206 let mut app = make_app();
1207
1208 app.on_session_update(&acp::SessionUpdate::UserMessageChunk(
1209 acp::ContentChunk::new(acp::ContentBlock::Image(acp::ImageContent::new(
1210 "aW1n",
1211 "image/png",
1212 ))),
1213 ));
1214 app.on_session_update(&acp::SessionUpdate::UserMessageChunk(
1215 acp::ContentChunk::new(acp::ContentBlock::Audio(acp::AudioContent::new(
1216 "YXVkaW8=",
1217 "audio/wav",
1218 ))),
1219 ));
1220
1221 let segments: Vec<_> = app.conversation_screen.conversation.segments().collect();
1222 assert!(matches!(
1223 segments[0],
1224 SegmentContent::UserMessage(text) if text == "[image attachment]"
1225 ));
1226 assert!(matches!(
1227 segments[1],
1228 SegmentContent::UserMessage(text) if text == "[audio attachment]"
1229 ));
1230 }
1231
1232 #[test]
1233 fn prompt_composer_open_settings() {
1234 let mut app = make_app();
1235 let mut commands = Vec::new();
1236 tokio::runtime::Runtime::new()
1237 .unwrap()
1238 .block_on(app.handle_conversation_messages(
1239 &mut commands,
1240 Some(vec![ConversationScreenMessage::OpenSettings]),
1241 ));
1242 assert!(
1243 app.settings_overlay.is_some(),
1244 "settings overlay should be opened"
1245 );
1246 }
1247
1248 #[test]
1249 fn settings_overlay_close_clears_overlay() {
1250 let mut app = make_app();
1251 app.open_settings_overlay();
1252 app.settings_overlay = None;
1253 assert!(app.settings_overlay.is_none(), "close should clear overlay");
1254 }
1255
1256 #[tokio::test]
1257 async fn tick_advances_spinner_animations() {
1258 let mut app = make_app();
1259 let tool_call = acp::ToolCall::new("tool-1".to_string(), "test_tool");
1260 app.conversation_screen
1261 .tool_call_statuses
1262 .on_tool_call(&tool_call);
1263 app.conversation_screen
1264 .progress_indicator
1265 .update(0, 1, true);
1266
1267 let ctx = ViewContext::new((80, 24));
1268 let tool_before = app
1269 .conversation_screen
1270 .tool_call_statuses
1271 .render_tool("tool-1", &ctx);
1272 let prog_before = app.conversation_screen.progress_indicator.render(&ctx);
1273
1274 app.on_event(&Event::Tick).await;
1275
1276 let tool_after = app
1277 .conversation_screen
1278 .tool_call_statuses
1279 .render_tool("tool-1", &ctx);
1280 let prog_after = app.conversation_screen.progress_indicator.render(&ctx);
1281
1282 assert_ne!(
1283 tool_before[0].plain_text(),
1284 tool_after[0].plain_text(),
1285 "tick should advance tool spinner"
1286 );
1287 assert_ne!(
1288 prog_before[0].plain_text(),
1289 prog_after[0].plain_text(),
1290 "tick should advance progress spinner"
1291 );
1292 }
1293
1294 #[test]
1295 fn on_prompt_error_clears_waiting_state() {
1296 let mut app = make_app();
1297 app.conversation_screen.waiting_for_response = true;
1298 app.conversation_screen
1299 .on_prompt_error(&acp::Error::internal_error());
1300 assert!(!app.conversation_screen.waiting_for_response);
1301 assert!(!app.exit_requested());
1302 }
1303
1304 #[test]
1305 fn auth_events_and_connection_close_exit_behavior() {
1306 let mut app = make_app_with_auth(vec![acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
1307 "anthropic",
1308 "Anthropic",
1309 ))]);
1310 app.on_authenticate_complete("anthropic");
1311 assert!(
1312 !app.exit_requested(),
1313 "authenticate_complete should not exit"
1314 );
1315
1316 let mut app = make_app();
1317 app.on_authenticate_failed("anthropic", "bad token");
1318 assert!(!app.exit_requested(), "authenticate_failed should not exit");
1319
1320 let mut app = make_app();
1321 app.on_acp_event(AcpEvent::ConnectionClosed);
1322 assert!(app.exit_requested(), "connection_closed should exit");
1323 }
1324
1325 #[tokio::test]
1326 async fn clear_screen_returns_clear_command() {
1327 let mut app = make_app();
1328 let mut commands = Vec::new();
1329 app.handle_conversation_messages(
1330 &mut commands,
1331 Some(vec![ConversationScreenMessage::ClearScreen]),
1332 )
1333 .await;
1334 assert!(
1335 commands
1336 .iter()
1337 .any(|c| matches!(c, RendererCommand::ClearScreen)),
1338 "should contain ClearScreen command"
1339 );
1340 }
1341
1342 #[tokio::test]
1343 async fn cancel_sends_directly_via_prompt_handle() {
1344 let mut app = make_app();
1345 app.conversation_screen.waiting_for_response = true;
1346 send_key(&mut app, KeyCode::Esc, KeyModifiers::NONE).await;
1347 assert!(!app.exit_requested());
1348 }
1349
1350 #[test]
1351 fn new_session_restores_changed_config_selections() {
1352 use acp_utils::client::PromptCommand;
1353
1354 let (mut app, mut rx) =
1355 make_app_with_config_recording(&mode_model_options("Planner", "gpt-4o"));
1356 app.update_config_options(&mode_model_options("Coder", "gpt-4o"));
1357
1358 app.on_acp_event(AcpEvent::NewSessionCreated {
1359 session_id: SessionId::new("new-session"),
1360 config_options: mode_model_options("Planner", "gpt-4o"),
1361 });
1362
1363 assert_eq!(app.session_id, SessionId::new("new-session"));
1364 assert!(app.context_usage_pct.is_none());
1365
1366 let cmd = rx.try_recv().expect("expected a SetConfigOption command");
1367 match cmd {
1368 PromptCommand::SetConfigOption {
1369 config_id, value, ..
1370 } => {
1371 assert_eq!(config_id, "mode");
1372 assert_eq!(value, "Coder");
1373 }
1374 other => panic!("expected SetConfigOption, got {other:?}"),
1375 }
1376 assert!(
1377 rx.try_recv().is_err(),
1378 "model was unchanged, no extra command expected"
1379 );
1380 }
1381}