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