Skip to main content

aether_cli/agent/new_agent_wizard/
wizard.rs

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(&current_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}