1use super::draft_agent_entry::DraftAgentEntry;
2use super::new_agent_step::{McpConfigFile, NewAgentMode, NewAgentOutcome, NewAgentStep, PromptFile};
3use super::steps::{IdentityStep, ModelStep, PromptsStep, StepCommand, ToolsStep, default_servers};
4use crate::error::CliError;
5use std::cmp::Ordering;
6use std::io::{self, Write};
7use std::process::{Command, Stdio};
8use tui::{
9 CrosstermEvent, Event, Frame, KeyCode, Line, StepVisualState, Stepper, StepperItem, Style, TerminalRuntime,
10 ViewContext,
11};
12use wisp::components::model_selector::ModelEntry;
13
14enum NewAgentAction {
15 Done(NewAgentOutcome),
16 EditSystemMd,
17}
18
19pub struct NewAgentWizard {
20 mode: NewAgentMode,
21 step: NewAgentStep,
22 draft: DraftAgentEntry,
23 identity: IdentityStep,
24 model: ModelStep,
25 prompts: PromptsStep,
26 tools: ToolsStep,
27 editor_error: Option<String>,
28}
29
30impl NewAgentWizard {
31 pub fn new(
32 mode: NewAgentMode,
33 model_entries: Vec<ModelEntry>,
34 prompt_options: &[PromptFile],
35 mcp_configs: &[McpConfigFile],
36 ) -> Self {
37 let prompts = PromptsStep::new(prompt_options);
38 Self {
39 mode,
40 step: NewAgentStep::Identity,
41 draft: DraftAgentEntry {
42 entry: aether_project::AgentEntry {
43 user_invocable: true,
44 agent_invocable: true,
45 prompts: prompt_options.iter().map(|d| d.filename().to_string()).collect(),
46 mcp_servers: default_servers(),
47 ..aether_project::AgentEntry::default()
48 },
49 system_md_content: String::new(),
50 system_md_edited: false,
51 workspace_mcp_configs: vec![],
52 },
53 identity: IdentityStep::new(),
54 model: ModelStep::new(model_entries),
55 prompts,
56 tools: ToolsStep::new(mcp_configs),
57 editor_error: None,
58 }
59 }
60
61 pub fn into_draft(self) -> DraftAgentEntry {
62 self.draft
63 }
64
65 fn sync_draft_from_step(&mut self) {
66 match self.step {
67 NewAgentStep::Identity => self.identity.sync_to_draft(&mut self.draft),
68 NewAgentStep::Model => self.model.sync_to_draft(&mut self.draft),
69 NewAgentStep::Prompts => self.prompts.sync_to_draft(&mut self.draft),
70 NewAgentStep::Tools => self.tools.sync_to_draft(&mut self.draft),
71 }
72 }
73
74 fn sync_step_from_draft(&mut self) {
75 match self.step {
76 NewAgentStep::Identity => self.identity.sync_from_draft(&self.draft),
77 NewAgentStep::Model | NewAgentStep::Tools => {}
78 NewAgentStep::Prompts => self.prompts.sync_from_draft(&mut self.draft),
79 }
80 }
81
82 async fn handle_event(&mut self, event: &Event) -> Option<NewAgentAction> {
83 if let Event::Key(key) = event {
84 if key.code == KeyCode::BackTab {
85 return self.advance_back();
86 }
87 if key.modifiers.is_empty() {
88 match key.code {
89 KeyCode::Esc => return Some(NewAgentAction::Done(NewAgentOutcome::Cancelled)),
90 KeyCode::Enter => return self.submit_step(),
91 KeyCode::Tab => return self.focus_next(),
92 _ => {}
93 }
94 }
95 }
96
97 let cmd = match self.step {
98 NewAgentStep::Identity => self.identity.handle_event(event).await,
99 NewAgentStep::Model => self.model.handle_event(event).await,
100 NewAgentStep::Prompts => self.prompts.handle_event(event).await,
101 NewAgentStep::Tools => self.tools.handle_event(event).await,
102 };
103
104 self.sync_draft_from_step();
105
106 match cmd {
107 StepCommand::EditSystemMd => Some(NewAgentAction::EditSystemMd),
108 StepCommand::None => None,
109 }
110 }
111
112 fn submit_step(&mut self) -> Option<NewAgentAction> {
113 self.sync_draft_from_step();
114 if !self.can_advance() {
115 return None;
116 }
117 match self.step.next() {
118 Some(next) => {
119 self.step = next;
120 self.sync_step_from_draft();
121 None
122 }
123 None => Some(NewAgentAction::Done(NewAgentOutcome::Applied)),
124 }
125 }
126
127 fn focus_next(&mut self) -> Option<NewAgentAction> {
128 match self.step {
129 NewAgentStep::Identity => {
130 if self.identity.focus_next() {
131 self.sync_draft_from_step();
132 }
133 }
134 NewAgentStep::Tools => {
135 self.tools.focus_next();
136 }
137 _ => {}
138 }
139 None
140 }
141
142 fn advance_back(&mut self) -> Option<NewAgentAction> {
143 if matches!(self.step, NewAgentStep::Identity) && self.identity.focus_prev() {
144 return None;
145 }
146 if matches!(self.step, NewAgentStep::Tools) && self.tools.focus_prev() {
147 return None;
148 }
149 if let Some(prev) = self.step.prev() {
150 self.sync_draft_from_step();
151 self.step = prev;
152 self.sync_step_from_draft();
153 if matches!(self.step, NewAgentStep::Identity) {
154 self.identity.focus_last();
155 }
156 }
157 None
158 }
159
160 fn can_advance(&self) -> bool {
161 match self.step {
162 NewAgentStep::Identity => {
163 let d = &self.draft.entry;
164 let name_ok = !d.name.trim().is_empty();
165 let desc_ok = !d.description.trim().is_empty();
166 let any_surface = d.user_invocable || d.agent_invocable;
167 let scaffold_ok = !matches!(
168 (&self.mode, d.user_invocable, d.agent_invocable),
169 (NewAgentMode::ScaffoldProject, false, true)
170 );
171 name_ok && desc_ok && any_surface && scaffold_ok
172 }
173 NewAgentStep::Model => !self.draft.entry.model.is_empty(),
174 NewAgentStep::Prompts | NewAgentStep::Tools => true,
175 }
176 }
177
178 fn render(&mut self, ctx: &ViewContext) -> Frame {
179 let w = ctx.size.width;
180 let h = ctx.size.height;
181 let header_h: u16 = 4;
182 let footer_h: u16 = 2;
183 let body_h = h.saturating_sub(header_h + footer_h);
184
185 if matches!(self.step, NewAgentStep::Model) {
186 self.model.update_viewport(body_h as usize);
187 }
188
189 let header = self.render_header(ctx).fit_height(header_h, w);
190 let footer = self.render_footer(ctx).fit_height(footer_h, w);
191 let body = Frame::new(self.render_body(ctx, w)).fit_height(body_h, w);
192
193 Frame::vstack([header, body, footer]).truncate_height(h)
194 }
195
196 fn render_header(&self, ctx: &ViewContext) -> Frame {
197 let title = match self.mode {
198 NewAgentMode::ScaffoldProject => "Create a new Aether project",
199 NewAgentMode::AddAgentToExistingProject => "Add a new agent",
200 };
201
202 let steps = NewAgentStep::all();
203 let current_idx = steps.iter().position(|s| *s == self.step).unwrap_or(0);
204 let items: Vec<StepperItem> = steps
205 .iter()
206 .enumerate()
207 .map(|(i, step)| StepperItem {
208 label: step.title(),
209 state: match i.cmp(¤t_idx) {
210 Ordering::Less => StepVisualState::Complete,
211 Ordering::Equal => StepVisualState::Current,
212 Ordering::Greater => StepVisualState::Upcoming,
213 },
214 })
215 .collect();
216 let stepper = Stepper { items: &items, separator: " \u{2500} ", leading_padding: 2 };
217
218 Frame::new(vec![
219 Line::styled(format!(" {title}"), ctx.theme.primary()),
220 Line::new(String::new()),
221 stepper.render(ctx),
222 Line::new(String::new()),
223 ])
224 }
225
226 fn render_footer(&self, ctx: &ViewContext) -> Frame {
227 let forward = if matches!(self.step, NewAgentStep::Tools) { "finish" } else { "next" };
228 let show_tab = matches!(self.step, NewAgentStep::Identity)
229 || (matches!(self.step, NewAgentStep::Tools) && self.tools.has_multiple_sections());
230 let tab_hint = if show_tab { "[tab] field " } else { "" };
231 let reasoning = if matches!(self.step, NewAgentStep::Model) { " [\u{2190}\u{2192}] reasoning" } else { "" };
232 let keys = format!(
233 " [enter] {forward} {tab_hint}[shift+tab] back [space] toggle [\u{2191}\u{2193}] move{reasoning} [esc] cancel"
234 );
235 Frame::new(vec![Line::new(String::new()), Line::styled(keys, ctx.theme.muted())])
236 }
237
238 fn render_body(&mut self, ctx: &ViewContext, pane_w: u16) -> Vec<Line> {
239 let mut lines = Vec::new();
240 lines.push(Line::with_style(format!(" {}", self.step.heading()), Style::fg(ctx.theme.heading()).bold()));
241 lines.push(Line::new(String::new()));
242
243 match self.step {
244 NewAgentStep::Identity => {
245 lines.extend(self.identity.render(ctx, pane_w, &self.draft, &self.mode));
246 }
247 NewAgentStep::Model => {
248 lines.extend(self.model.render(ctx));
249 }
250 NewAgentStep::Prompts => {
251 let system_md_path = self.draft.generated_paths(&self.mode).system_md.display().to_string();
252 lines.extend(self.prompts.render(ctx, pane_w, &self.draft.system_md_content, &system_md_path));
253 if let Some(err) = &self.editor_error {
254 lines.push(Line::styled(format!(" \u{26a0} {err}"), ctx.theme.warning()));
255 }
256 }
257 NewAgentStep::Tools => {
258 lines.extend(self.tools.render(ctx));
259 }
260 }
261
262 lines
263 }
264}
265
266pub async fn run_wizard_loop<W: io::Write>(
267 wizard: &mut NewAgentWizard,
268 terminal: &mut TerminalRuntime<W>,
269) -> Result<NewAgentOutcome, CliError> {
270 terminal.render_frame(|ctx| wizard.render(ctx)).map_err(CliError::IoError)?;
271
272 loop {
273 let Some(event) = terminal.next_event().await else {
274 return Ok(NewAgentOutcome::Cancelled);
275 };
276 if let CrosstermEvent::Resize(c, r) = &event {
277 terminal.on_resize((*c, *r));
278 }
279 if let Ok(tui_event) = Event::try_from(event) {
280 match wizard.handle_event(&tui_event).await {
281 Some(NewAgentAction::Done(outcome)) => return Ok(outcome),
282 Some(NewAgentAction::EditSystemMd) => {
283 let editor = std::env::var("VISUAL")
284 .or_else(|_| std::env::var("EDITOR"))
285 .unwrap_or_else(|_| "vi".to_string());
286 edit_system_md(wizard, terminal, &editor).await?;
287 }
288 None => {}
289 }
290 terminal.render_frame(|ctx| wizard.render(ctx)).map_err(CliError::IoError)?;
291 }
292 }
293}
294
295async fn edit_system_md<W: io::Write>(
296 wizard: &mut NewAgentWizard,
297 terminal: &mut TerminalRuntime<W>,
298 editor: &str,
299) -> Result<(), CliError> {
300 let mut tmp =
301 tempfile::Builder::new().prefix("aether-system-").suffix(".md").tempfile().map_err(CliError::IoError)?;
302 tmp.write_all(wizard.draft.system_md_content.as_bytes()).map_err(CliError::IoError)?;
303 tmp.flush().map_err(CliError::IoError)?;
304 let path = tmp.path().to_path_buf();
305
306 let mut command = Command::new(editor);
307 command.arg(&path).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
308
309 if let Err(err) = terminal.run_external(command).await {
310 tracing::warn!(%editor, %err, "failed to open editor");
311 wizard.editor_error = Some(format!("Could not open editor '{editor}': {err}"));
312 return Ok(());
313 }
314
315 wizard.editor_error = None;
316
317 let edited = std::fs::read_to_string(&path).map_err(CliError::IoError)?;
318 wizard.draft.system_md_content = edited;
319 wizard.draft.system_md_edited = true;
320 Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use llm::ReasoningEffort;
327 use tui::KeyModifiers;
328 use wisp::components::model_selector::{ModelEntry, ModelSelector};
329
330 fn make_wizard(mode: NewAgentMode) -> NewAgentWizard {
331 NewAgentWizard::new(mode, vec![], &[PromptFile::Agents], &[])
332 }
333
334 #[test]
335 fn scaffold_mode_blocks_agent_only_exposure() {
336 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
337 wizard.identity.name.inner.value = "Test".to_string();
338 wizard.identity.description.inner.value = "Test agent".to_string();
339 wizard.draft.entry.name = "Test".to_string();
340 wizard.draft.entry.description = "Test agent".to_string();
341 wizard.draft.entry.user_invocable = false;
342 wizard.draft.entry.agent_invocable = true;
343 wizard.step = NewAgentStep::Identity;
344
345 assert!(!wizard.can_advance());
346 }
347
348 #[test]
349 fn scaffold_mode_allows_user_only_exposure() {
350 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
351 wizard.identity.name.inner.value = "Test".to_string();
352 wizard.identity.description.inner.value = "Test agent".to_string();
353 wizard.draft.entry.name = "Test".to_string();
354 wizard.draft.entry.description = "Test agent".to_string();
355 wizard.draft.entry.user_invocable = true;
356 wizard.draft.entry.agent_invocable = false;
357 wizard.step = NewAgentStep::Identity;
358
359 assert!(wizard.can_advance());
360 }
361
362 #[test]
363 fn add_agent_mode_allows_agent_only_exposure() {
364 let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
365 wizard.identity.name.inner.value = "Test".to_string();
366 wizard.identity.description.inner.value = "Test agent".to_string();
367 wizard.draft.entry.name = "Test".to_string();
368 wizard.draft.entry.description = "Test agent".to_string();
369 wizard.draft.entry.user_invocable = false;
370 wizard.draft.entry.agent_invocable = true;
371 wizard.step = NewAgentStep::Identity;
372
373 assert!(wizard.can_advance());
374 }
375
376 #[test]
377 fn both_surfaces_unchecked_blocks_advance() {
378 let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
379 wizard.identity.name.inner.value = "Test".to_string();
380 wizard.identity.description.inner.value = "Test agent".to_string();
381 wizard.draft.entry.name = "Test".to_string();
382 wizard.draft.entry.description = "Test agent".to_string();
383 wizard.draft.entry.user_invocable = false;
384 wizard.draft.entry.agent_invocable = false;
385 wizard.step = NewAgentStep::Identity;
386
387 assert!(!wizard.can_advance());
388 }
389
390 fn key_event(code: KeyCode) -> Event {
391 Event::Key(tui::KeyEvent {
392 code,
393 modifiers: KeyModifiers::NONE,
394 kind: tui::KeyEventKind::Press,
395 state: tui::KeyEventState::empty(),
396 })
397 }
398
399 #[tokio::test]
400 async fn identity_typing_only_updates_focused_field() {
401 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
402 wizard.step = NewAgentStep::Identity;
403
404 wizard.handle_event(&key_event(KeyCode::Char('a'))).await;
405 assert_eq!(wizard.draft.entry.name, "a");
406 assert_eq!(wizard.draft.entry.description, "");
407
408 wizard.handle_event(&key_event(KeyCode::Tab)).await;
409 wizard.handle_event(&key_event(KeyCode::Char('b'))).await;
410 assert_eq!(wizard.draft.entry.name, "a");
411 assert_eq!(wizard.draft.entry.description, "b");
412 }
413
414 #[tokio::test]
415 async fn identity_tab_cycles_focus_past_text_fields() {
416 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
417 wizard.step = NewAgentStep::Identity;
418
419 wizard.handle_event(&key_event(KeyCode::Tab)).await;
420 wizard.handle_event(&key_event(KeyCode::Tab)).await;
421 wizard.handle_event(&key_event(KeyCode::Char('x'))).await;
422
423 assert_eq!(wizard.draft.entry.name, "");
424 assert_eq!(wizard.draft.entry.description, "");
425 }
426
427 #[tokio::test]
428 async fn identity_enter_does_not_move_focus() {
429 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
430 wizard.step = NewAgentStep::Identity;
431
432 wizard.handle_event(&key_event(KeyCode::Char('a'))).await;
433 wizard.handle_event(&key_event(KeyCode::Enter)).await;
434 wizard.handle_event(&key_event(KeyCode::Char('b'))).await;
435
436 assert_eq!(wizard.draft.entry.name, "ab");
437 assert_eq!(wizard.draft.entry.description, "");
438 assert_eq!(wizard.identity.focus.focused(), 0);
439 }
440
441 #[tokio::test]
442 async fn identity_enter_on_last_field_advances_step_when_valid() {
443 let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
444 wizard.step = NewAgentStep::Identity;
445 wizard.identity.name.set_value("Test".to_string());
446 wizard.identity.description.set_value("Test agent".to_string());
447 wizard.draft.entry.name = "Test".to_string();
448 wizard.draft.entry.description = "Test agent".to_string();
449 wizard.draft.entry.user_invocable = true;
450 wizard.draft.entry.agent_invocable = true;
451 wizard.identity.focus.focus(2);
452
453 wizard.handle_event(&key_event(KeyCode::Enter)).await;
454
455 assert_eq!(wizard.step, NewAgentStep::Model);
456 }
457
458 #[tokio::test]
459 async fn identity_enter_on_last_field_blocks_when_invalid() {
460 let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
461 wizard.step = NewAgentStep::Identity;
462 wizard.identity.focus.focus(2);
463
464 wizard.handle_event(&key_event(KeyCode::Enter)).await;
465
466 assert_eq!(wizard.step, NewAgentStep::Identity);
467 }
468
469 #[tokio::test]
470 async fn tab_on_last_identity_field_is_noop() {
471 let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
472 wizard.step = NewAgentStep::Identity;
473 wizard.identity.name.set_value("Test".to_string());
474 wizard.identity.description.set_value("Test agent".to_string());
475 wizard.draft.entry.name = "Test".to_string();
476 wizard.draft.entry.description = "Test agent".to_string();
477 wizard.draft.entry.user_invocable = true;
478 wizard.draft.entry.agent_invocable = true;
479 wizard.identity.focus.focus(2);
480
481 wizard.handle_event(&key_event(KeyCode::Tab)).await;
482
483 assert_eq!(wizard.identity.focus.focused(), 2);
484 assert_eq!(wizard.step, NewAgentStep::Identity);
485 }
486
487 #[tokio::test]
488 async fn enter_on_first_identity_field_advances_step_when_valid() {
489 let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
490 wizard.step = NewAgentStep::Identity;
491 wizard.identity.name.set_value("Test".to_string());
492 wizard.identity.description.set_value("Test agent".to_string());
493 wizard.draft.entry.name = "Test".to_string();
494 wizard.draft.entry.description = "Test agent".to_string();
495 wizard.draft.entry.user_invocable = true;
496 wizard.draft.entry.agent_invocable = true;
497 wizard.identity.focus.focus(0);
498
499 wizard.handle_event(&key_event(KeyCode::Enter)).await;
500
501 assert_eq!(wizard.step, NewAgentStep::Model);
502 }
503
504 #[tokio::test]
505 async fn back_tab_moves_focus_back_within_identity() {
506 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
507 wizard.step = NewAgentStep::Identity;
508 wizard.identity.focus.focus(2);
509
510 wizard.handle_event(&key_event(KeyCode::BackTab)).await;
511
512 assert_eq!(wizard.identity.focus.focused(), 1);
513 assert_eq!(wizard.step, NewAgentStep::Identity);
514 }
515
516 #[tokio::test]
517 async fn back_tab_on_first_identity_field_is_noop() {
518 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
519 wizard.step = NewAgentStep::Identity;
520
521 wizard.handle_event(&key_event(KeyCode::BackTab)).await;
522
523 assert_eq!(wizard.identity.focus.focused(), 0);
524 assert_eq!(wizard.step, NewAgentStep::Identity);
525 }
526
527 #[tokio::test]
528 async fn back_tab_from_model_returns_to_identity_last_field() {
529 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
530 wizard.step = NewAgentStep::Model;
531
532 wizard.handle_event(&key_event(KeyCode::BackTab)).await;
533
534 assert_eq!(wizard.step, NewAgentStep::Identity);
535 assert_eq!(wizard.identity.focus.focused(), 2);
536 }
537
538 fn single_model_entries() -> Vec<ModelEntry> {
539 vec![ModelEntry {
540 value: "test:model-a".to_string(),
541 name: "Test / Model A".to_string(),
542 reasoning_levels: vec![],
543 supports_image: false,
544 supports_audio: false,
545 disabled_reason: None,
546 }]
547 }
548
549 fn reasoning_model_entries() -> Vec<ModelEntry> {
550 vec![ModelEntry {
551 value: "test:model-r".to_string(),
552 name: "Test / Model R".to_string(),
553 reasoning_levels: vec![ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High],
554 supports_image: false,
555 supports_audio: false,
556 disabled_reason: None,
557 }]
558 }
559
560 #[tokio::test]
561 async fn model_step_space_toggles_focused_model() {
562 let mut wizard =
563 NewAgentWizard::new(NewAgentMode::ScaffoldProject, single_model_entries(), &[PromptFile::Agents], &[]);
564 wizard.step = NewAgentStep::Model;
565
566 wizard.handle_event(&key_event(KeyCode::Char(' '))).await;
567
568 assert_eq!(wizard.draft.entry.model, "test:model-a");
569 }
570
571 #[tokio::test]
572 async fn model_step_enter_advances_when_model_selected() {
573 let mut wizard =
574 NewAgentWizard::new(NewAgentMode::ScaffoldProject, single_model_entries(), &[PromptFile::Agents], &[]);
575 wizard.step = NewAgentStep::Model;
576
577 wizard.handle_event(&key_event(KeyCode::Char(' '))).await;
578 wizard.handle_event(&key_event(KeyCode::Enter)).await;
579
580 assert_eq!(wizard.step, NewAgentStep::Prompts);
581 }
582
583 #[tokio::test]
584 async fn model_step_enter_blocks_without_selection() {
585 let mut wizard =
586 NewAgentWizard::new(NewAgentMode::ScaffoldProject, single_model_entries(), &[PromptFile::Agents], &[]);
587 wizard.step = NewAgentStep::Model;
588
589 wizard.handle_event(&key_event(KeyCode::Enter)).await;
590
591 assert_eq!(wizard.step, NewAgentStep::Model);
592 }
593
594 #[tokio::test]
595 async fn model_step_right_cycles_reasoning_forward() {
596 let mut wizard =
597 NewAgentWizard::new(NewAgentMode::ScaffoldProject, reasoning_model_entries(), &[PromptFile::Agents], &[]);
598 wizard.step = NewAgentStep::Model;
599
600 wizard.handle_event(&key_event(KeyCode::Right)).await;
601 assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::Low));
602
603 wizard.handle_event(&key_event(KeyCode::Right)).await;
604 assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::Medium));
605
606 wizard.handle_event(&key_event(KeyCode::Right)).await;
607 assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::High));
608
609 wizard.handle_event(&key_event(KeyCode::Right)).await;
610 assert_eq!(wizard.draft.entry.reasoning_effort, None);
611 }
612
613 #[tokio::test]
614 async fn model_step_left_cycles_reasoning_backward() {
615 let mut wizard =
616 NewAgentWizard::new(NewAgentMode::ScaffoldProject, reasoning_model_entries(), &[PromptFile::Agents], &[]);
617 wizard.step = NewAgentStep::Model;
618
619 wizard.handle_event(&key_event(KeyCode::Left)).await;
620 assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::High));
621
622 wizard.handle_event(&key_event(KeyCode::Left)).await;
623 assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::Medium));
624
625 wizard.handle_event(&key_event(KeyCode::Left)).await;
626 assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::Low));
627
628 wizard.handle_event(&key_event(KeyCode::Left)).await;
629 assert_eq!(wizard.draft.entry.reasoning_effort, None);
630 }
631
632 #[tokio::test]
633 async fn tools_step_enter_finishes_wizard() {
634 let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
635 wizard.step = NewAgentStep::Tools;
636
637 let outcome = wizard.handle_event(&key_event(KeyCode::Enter)).await;
638
639 assert!(matches!(outcome, Some(NewAgentAction::Done(NewAgentOutcome::Applied))));
640 }
641
642 #[tokio::test]
643 async fn prompts_step_e_key_requests_editor() {
644 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
645 wizard.step = NewAgentStep::Prompts;
646
647 let outcome = wizard.handle_event(&key_event(KeyCode::Char('e'))).await;
648
649 assert!(matches!(outcome, Some(NewAgentAction::EditSystemMd)));
650 }
651
652 #[tokio::test]
653 async fn prompts_step_seeds_system_md_from_draft() {
654 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
655 wizard.draft.entry.name = "Researcher".to_string();
656 wizard.draft.entry.description = "Research agent".to_string();
657 wizard.step = NewAgentStep::Prompts;
658 wizard.sync_step_from_draft();
659
660 assert!(wizard.draft.system_md_content.starts_with("# Researcher\n"));
661 assert!(wizard.draft.system_md_content.contains("Research agent"));
662 }
663
664 #[tokio::test]
665 async fn prompts_step_preserves_edited_system_md_on_rerender() {
666 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
667 wizard.draft.entry.name = "Researcher".to_string();
668 wizard.draft.entry.description = "Research agent".to_string();
669 wizard.draft.system_md_content = "# Custom body".to_string();
670 wizard.draft.system_md_edited = true;
671 wizard.step = NewAgentStep::Prompts;
672 wizard.sync_step_from_draft();
673
674 assert_eq!(wizard.draft.system_md_content, "# Custom body");
675 }
676
677 #[tokio::test]
678 async fn model_selector_syncs_to_draft() {
679 let entries = vec![ModelEntry {
680 value: "test:model-a".to_string(),
681 name: "Test / Model A".to_string(),
682 reasoning_levels: vec![ReasoningEffort::Medium, ReasoningEffort::High],
683 supports_image: false,
684 supports_audio: false,
685 disabled_reason: None,
686 }];
687 let selector = ModelSelector::new(entries, "model".to_string(), Some("test:model-a"), Some("high"));
688
689 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
690 wizard.step = NewAgentStep::Model;
691 wizard.model.selector = selector;
692
693 wizard.sync_draft_from_step();
694
695 assert_eq!(wizard.draft.entry.model, "test:model-a");
696 assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::High));
697 }
698
699 fn make_wizard_with_mcp_configs(mode: NewAgentMode) -> NewAgentWizard {
700 NewAgentWizard::new(mode, vec![], &[PromptFile::Agents], &[McpConfigFile::McpJson])
701 }
702
703 #[tokio::test]
704 async fn tools_tab_moves_focus_to_mcp_configs() {
705 let mut wizard = make_wizard_with_mcp_configs(NewAgentMode::ScaffoldProject);
706 wizard.step = NewAgentStep::Tools;
707
708 assert_eq!(wizard.tools.focus, 0);
709 wizard.handle_event(&key_event(KeyCode::Tab)).await;
710 assert_eq!(wizard.tools.focus, 1);
711 }
712
713 #[tokio::test]
714 async fn tools_back_tab_moves_focus_back_from_mcp_configs() {
715 let mut wizard = make_wizard_with_mcp_configs(NewAgentMode::ScaffoldProject);
716 wizard.step = NewAgentStep::Tools;
717 wizard.tools.focus = 1;
718
719 wizard.handle_event(&key_event(KeyCode::BackTab)).await;
720
721 assert_eq!(wizard.tools.focus, 0);
722 assert_eq!(wizard.step, NewAgentStep::Tools);
723 }
724
725 #[tokio::test]
726 async fn tools_back_tab_on_first_section_goes_to_prev_step() {
727 let mut wizard = make_wizard_with_mcp_configs(NewAgentMode::ScaffoldProject);
728 wizard.step = NewAgentStep::Tools;
729
730 wizard.handle_event(&key_event(KeyCode::BackTab)).await;
731
732 assert_eq!(wizard.step, NewAgentStep::Prompts);
733 }
734
735 #[tokio::test]
736 async fn tools_tab_without_mcp_configs_is_noop() {
737 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
738 wizard.step = NewAgentStep::Tools;
739
740 wizard.handle_event(&key_event(KeyCode::Tab)).await;
741
742 assert_eq!(wizard.tools.focus, 0);
743 assert_eq!(wizard.step, NewAgentStep::Tools);
744 }
745
746 #[tokio::test]
747 async fn tools_mcp_config_toggle_syncs_to_draft() {
748 let mut wizard = make_wizard_with_mcp_configs(NewAgentMode::ScaffoldProject);
749 wizard.step = NewAgentStep::Tools;
750 wizard.tools.focus = 1;
751
752 wizard.handle_event(&key_event(KeyCode::Char(' '))).await;
753
754 assert!(wizard.draft.workspace_mcp_configs.contains(&"mcp.json".to_string()));
755 }
756
757 #[tokio::test]
758 async fn edit_system_md_with_missing_editor_sets_error() {
759 let (w, h) = (80, 24);
760 let mut wizard = {
761 let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
762 wizard.step = NewAgentStep::Prompts;
763 wizard.draft.system_md_content = "original content".to_string();
764 wizard
765 };
766
767 let mut terminal = TerminalRuntime::headless(Vec::<u8>::new(), (w, h));
768 let result = edit_system_md(&mut wizard, &mut terminal, "__nonexistent_editor__").await;
769
770 assert!(result.is_ok(), "edit_system_md should not return an error");
771 assert_eq!(wizard.draft.system_md_content, "original content", "content should be unchanged");
772 assert!(!wizard.draft.system_md_edited, "edited flag should remain false");
773 assert!(wizard.editor_error.is_some(), "editor_error should be set");
774 assert!(wizard.editor_error.as_ref().unwrap().contains("__nonexistent_editor__"));
775
776 let ctx = ViewContext::new((w, h));
777 let lines = wizard.render_body(&ctx, w);
778 let text: Vec<String> = lines.iter().map(Line::plain_text).collect();
779 assert!(
780 text.iter().any(|l| l.contains("Could not open editor")),
781 "expected editor error in rendered output, got: {text:?}"
782 );
783 }
784}