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