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    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 if self.identity.focus_next() => {
130                self.sync_draft_from_step();
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                if let Some(err) = &self.editor_error {
252                    lines.push(Line::styled(format!("  \u{26a0} {err}"), ctx.theme.warning()));
253                }
254            }
255            NewAgentStep::Tools => {
256                lines.extend(self.tools.render(ctx));
257            }
258        }
259
260        lines
261    }
262}
263
264pub async fn run_wizard_loop<W: io::Write>(
265    wizard: &mut NewAgentWizard,
266    terminal: &mut TerminalRuntime<W>,
267) -> Result<NewAgentOutcome, CliError> {
268    terminal.render_frame(|ctx| wizard.render(ctx)).map_err(CliError::IoError)?;
269
270    loop {
271        let Some(event) = terminal.next_event().await else {
272            return Ok(NewAgentOutcome::Cancelled);
273        };
274        if let CrosstermEvent::Resize(c, r) = &event {
275            terminal.on_resize((*c, *r));
276        }
277        if let Ok(tui_event) = Event::try_from(event) {
278            match wizard.handle_event(&tui_event).await {
279                Some(NewAgentAction::Done(outcome)) => return Ok(outcome),
280                Some(NewAgentAction::EditSystemMd) => {
281                    let editor = std::env::var("VISUAL")
282                        .or_else(|_| std::env::var("EDITOR"))
283                        .unwrap_or_else(|_| "vi".to_string());
284                    edit_system_md(wizard, terminal, &editor).await?;
285                }
286                None => {}
287            }
288            terminal.render_frame(|ctx| wizard.render(ctx)).map_err(CliError::IoError)?;
289        }
290    }
291}
292
293async fn edit_system_md<W: io::Write>(
294    wizard: &mut NewAgentWizard,
295    terminal: &mut TerminalRuntime<W>,
296    editor: &str,
297) -> Result<(), CliError> {
298    let mut tmp =
299        tempfile::Builder::new().prefix("aether-system-").suffix(".md").tempfile().map_err(CliError::IoError)?;
300    tmp.write_all(wizard.draft.system_md_content.as_bytes()).map_err(CliError::IoError)?;
301    tmp.flush().map_err(CliError::IoError)?;
302    let path = tmp.path().to_path_buf();
303
304    let mut command = Command::new(editor);
305    command.arg(&path).stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit());
306
307    if let Err(err) = terminal.run_external(command).await {
308        tracing::warn!(%editor, %err, "failed to open editor");
309        wizard.editor_error = Some(format!("Could not open editor '{editor}': {err}"));
310        return Ok(());
311    }
312
313    wizard.editor_error = None;
314
315    let edited = std::fs::read_to_string(&path).map_err(CliError::IoError)?;
316    wizard.draft.system_md_content = edited;
317    wizard.draft.system_md_edited = true;
318    Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use llm::ReasoningEffort;
325    use tui::KeyModifiers;
326    use wisp::components::model_selector::{ModelEntry, ModelSelector};
327
328    fn make_wizard(mode: NewAgentMode) -> NewAgentWizard {
329        NewAgentWizard::new(mode, vec![], &[PromptFile::Agents], &[])
330    }
331
332    #[test]
333    fn scaffold_mode_blocks_agent_only_exposure() {
334        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
335        wizard.identity.name.inner.value = "Test".to_string();
336        wizard.identity.description.inner.value = "Test agent".to_string();
337        wizard.draft.entry.name = "Test".to_string();
338        wizard.draft.entry.description = "Test agent".to_string();
339        wizard.draft.entry.user_invocable = false;
340        wizard.draft.entry.agent_invocable = true;
341        wizard.step = NewAgentStep::Identity;
342
343        assert!(!wizard.can_advance());
344    }
345
346    #[test]
347    fn scaffold_mode_allows_user_only_exposure() {
348        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
349        wizard.identity.name.inner.value = "Test".to_string();
350        wizard.identity.description.inner.value = "Test agent".to_string();
351        wizard.draft.entry.name = "Test".to_string();
352        wizard.draft.entry.description = "Test agent".to_string();
353        wizard.draft.entry.user_invocable = true;
354        wizard.draft.entry.agent_invocable = false;
355        wizard.step = NewAgentStep::Identity;
356
357        assert!(wizard.can_advance());
358    }
359
360    #[test]
361    fn add_agent_mode_allows_agent_only_exposure() {
362        let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
363        wizard.identity.name.inner.value = "Test".to_string();
364        wizard.identity.description.inner.value = "Test agent".to_string();
365        wizard.draft.entry.name = "Test".to_string();
366        wizard.draft.entry.description = "Test agent".to_string();
367        wizard.draft.entry.user_invocable = false;
368        wizard.draft.entry.agent_invocable = true;
369        wizard.step = NewAgentStep::Identity;
370
371        assert!(wizard.can_advance());
372    }
373
374    #[test]
375    fn both_surfaces_unchecked_blocks_advance() {
376        let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
377        wizard.identity.name.inner.value = "Test".to_string();
378        wizard.identity.description.inner.value = "Test agent".to_string();
379        wizard.draft.entry.name = "Test".to_string();
380        wizard.draft.entry.description = "Test agent".to_string();
381        wizard.draft.entry.user_invocable = false;
382        wizard.draft.entry.agent_invocable = false;
383        wizard.step = NewAgentStep::Identity;
384
385        assert!(!wizard.can_advance());
386    }
387
388    fn key_event(code: KeyCode) -> Event {
389        Event::Key(tui::KeyEvent {
390            code,
391            modifiers: KeyModifiers::NONE,
392            kind: tui::KeyEventKind::Press,
393            state: tui::KeyEventState::empty(),
394        })
395    }
396
397    #[tokio::test]
398    async fn identity_typing_only_updates_focused_field() {
399        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
400        wizard.step = NewAgentStep::Identity;
401
402        wizard.handle_event(&key_event(KeyCode::Char('a'))).await;
403        assert_eq!(wizard.draft.entry.name, "a");
404        assert_eq!(wizard.draft.entry.description, "");
405
406        wizard.handle_event(&key_event(KeyCode::Tab)).await;
407        wizard.handle_event(&key_event(KeyCode::Char('b'))).await;
408        assert_eq!(wizard.draft.entry.name, "a");
409        assert_eq!(wizard.draft.entry.description, "b");
410    }
411
412    #[tokio::test]
413    async fn identity_tab_cycles_focus_past_text_fields() {
414        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
415        wizard.step = NewAgentStep::Identity;
416
417        wizard.handle_event(&key_event(KeyCode::Tab)).await;
418        wizard.handle_event(&key_event(KeyCode::Tab)).await;
419        wizard.handle_event(&key_event(KeyCode::Char('x'))).await;
420
421        assert_eq!(wizard.draft.entry.name, "");
422        assert_eq!(wizard.draft.entry.description, "");
423    }
424
425    #[tokio::test]
426    async fn identity_enter_does_not_move_focus() {
427        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
428        wizard.step = NewAgentStep::Identity;
429
430        wizard.handle_event(&key_event(KeyCode::Char('a'))).await;
431        wizard.handle_event(&key_event(KeyCode::Enter)).await;
432        wizard.handle_event(&key_event(KeyCode::Char('b'))).await;
433
434        assert_eq!(wizard.draft.entry.name, "ab");
435        assert_eq!(wizard.draft.entry.description, "");
436        assert_eq!(wizard.identity.focus.focused(), 0);
437    }
438
439    #[tokio::test]
440    async fn identity_enter_on_last_field_advances_step_when_valid() {
441        let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
442        wizard.step = NewAgentStep::Identity;
443        wizard.identity.name.set_value("Test".to_string());
444        wizard.identity.description.set_value("Test agent".to_string());
445        wizard.draft.entry.name = "Test".to_string();
446        wizard.draft.entry.description = "Test agent".to_string();
447        wizard.draft.entry.user_invocable = true;
448        wizard.draft.entry.agent_invocable = true;
449        wizard.identity.focus.focus(2);
450
451        wizard.handle_event(&key_event(KeyCode::Enter)).await;
452
453        assert_eq!(wizard.step, NewAgentStep::Model);
454    }
455
456    #[tokio::test]
457    async fn identity_enter_on_last_field_blocks_when_invalid() {
458        let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
459        wizard.step = NewAgentStep::Identity;
460        wizard.identity.focus.focus(2);
461
462        wizard.handle_event(&key_event(KeyCode::Enter)).await;
463
464        assert_eq!(wizard.step, NewAgentStep::Identity);
465    }
466
467    #[tokio::test]
468    async fn tab_on_last_identity_field_is_noop() {
469        let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
470        wizard.step = NewAgentStep::Identity;
471        wizard.identity.name.set_value("Test".to_string());
472        wizard.identity.description.set_value("Test agent".to_string());
473        wizard.draft.entry.name = "Test".to_string();
474        wizard.draft.entry.description = "Test agent".to_string();
475        wizard.draft.entry.user_invocable = true;
476        wizard.draft.entry.agent_invocable = true;
477        wizard.identity.focus.focus(2);
478
479        wizard.handle_event(&key_event(KeyCode::Tab)).await;
480
481        assert_eq!(wizard.identity.focus.focused(), 2);
482        assert_eq!(wizard.step, NewAgentStep::Identity);
483    }
484
485    #[tokio::test]
486    async fn enter_on_first_identity_field_advances_step_when_valid() {
487        let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
488        wizard.step = NewAgentStep::Identity;
489        wizard.identity.name.set_value("Test".to_string());
490        wizard.identity.description.set_value("Test agent".to_string());
491        wizard.draft.entry.name = "Test".to_string();
492        wizard.draft.entry.description = "Test agent".to_string();
493        wizard.draft.entry.user_invocable = true;
494        wizard.draft.entry.agent_invocable = true;
495        wizard.identity.focus.focus(0);
496
497        wizard.handle_event(&key_event(KeyCode::Enter)).await;
498
499        assert_eq!(wizard.step, NewAgentStep::Model);
500    }
501
502    #[tokio::test]
503    async fn back_tab_moves_focus_back_within_identity() {
504        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
505        wizard.step = NewAgentStep::Identity;
506        wizard.identity.focus.focus(2);
507
508        wizard.handle_event(&key_event(KeyCode::BackTab)).await;
509
510        assert_eq!(wizard.identity.focus.focused(), 1);
511        assert_eq!(wizard.step, NewAgentStep::Identity);
512    }
513
514    #[tokio::test]
515    async fn back_tab_on_first_identity_field_is_noop() {
516        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
517        wizard.step = NewAgentStep::Identity;
518
519        wizard.handle_event(&key_event(KeyCode::BackTab)).await;
520
521        assert_eq!(wizard.identity.focus.focused(), 0);
522        assert_eq!(wizard.step, NewAgentStep::Identity);
523    }
524
525    #[tokio::test]
526    async fn back_tab_from_model_returns_to_identity_last_field() {
527        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
528        wizard.step = NewAgentStep::Model;
529
530        wizard.handle_event(&key_event(KeyCode::BackTab)).await;
531
532        assert_eq!(wizard.step, NewAgentStep::Identity);
533        assert_eq!(wizard.identity.focus.focused(), 2);
534    }
535
536    fn single_model_entries() -> Vec<ModelEntry> {
537        vec![ModelEntry {
538            value: "test:model-a".to_string(),
539            name: "Test / Model A".to_string(),
540            reasoning_levels: vec![],
541            supports_image: false,
542            supports_audio: false,
543            disabled_reason: None,
544        }]
545    }
546
547    fn reasoning_model_entries() -> Vec<ModelEntry> {
548        vec![ModelEntry {
549            value: "test:model-r".to_string(),
550            name: "Test / Model R".to_string(),
551            reasoning_levels: vec![ReasoningEffort::Low, ReasoningEffort::Medium, ReasoningEffort::High],
552            supports_image: false,
553            supports_audio: false,
554            disabled_reason: None,
555        }]
556    }
557
558    #[tokio::test]
559    async fn model_step_space_toggles_focused_model() {
560        let mut wizard =
561            NewAgentWizard::new(NewAgentMode::ScaffoldProject, single_model_entries(), &[PromptFile::Agents], &[]);
562        wizard.step = NewAgentStep::Model;
563
564        wizard.handle_event(&key_event(KeyCode::Char(' '))).await;
565
566        assert_eq!(wizard.draft.entry.model, "test:model-a");
567    }
568
569    #[tokio::test]
570    async fn model_step_enter_advances_when_model_selected() {
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::Char(' '))).await;
576        wizard.handle_event(&key_event(KeyCode::Enter)).await;
577
578        assert_eq!(wizard.step, NewAgentStep::Prompts);
579    }
580
581    #[tokio::test]
582    async fn model_step_enter_blocks_without_selection() {
583        let mut wizard =
584            NewAgentWizard::new(NewAgentMode::ScaffoldProject, single_model_entries(), &[PromptFile::Agents], &[]);
585        wizard.step = NewAgentStep::Model;
586
587        wizard.handle_event(&key_event(KeyCode::Enter)).await;
588
589        assert_eq!(wizard.step, NewAgentStep::Model);
590    }
591
592    #[tokio::test]
593    async fn model_step_right_cycles_reasoning_forward() {
594        let mut wizard =
595            NewAgentWizard::new(NewAgentMode::ScaffoldProject, reasoning_model_entries(), &[PromptFile::Agents], &[]);
596        wizard.step = NewAgentStep::Model;
597
598        wizard.handle_event(&key_event(KeyCode::Right)).await;
599        assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::Low));
600
601        wizard.handle_event(&key_event(KeyCode::Right)).await;
602        assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::Medium));
603
604        wizard.handle_event(&key_event(KeyCode::Right)).await;
605        assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::High));
606
607        wizard.handle_event(&key_event(KeyCode::Right)).await;
608        assert_eq!(wizard.draft.entry.reasoning_effort, None);
609    }
610
611    #[tokio::test]
612    async fn model_step_left_cycles_reasoning_backward() {
613        let mut wizard =
614            NewAgentWizard::new(NewAgentMode::ScaffoldProject, reasoning_model_entries(), &[PromptFile::Agents], &[]);
615        wizard.step = NewAgentStep::Model;
616
617        wizard.handle_event(&key_event(KeyCode::Left)).await;
618        assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::High));
619
620        wizard.handle_event(&key_event(KeyCode::Left)).await;
621        assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::Medium));
622
623        wizard.handle_event(&key_event(KeyCode::Left)).await;
624        assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::Low));
625
626        wizard.handle_event(&key_event(KeyCode::Left)).await;
627        assert_eq!(wizard.draft.entry.reasoning_effort, None);
628    }
629
630    #[tokio::test]
631    async fn tools_step_enter_finishes_wizard() {
632        let mut wizard = make_wizard(NewAgentMode::AddAgentToExistingProject);
633        wizard.step = NewAgentStep::Tools;
634
635        let outcome = wizard.handle_event(&key_event(KeyCode::Enter)).await;
636
637        assert!(matches!(outcome, Some(NewAgentAction::Done(NewAgentOutcome::Applied))));
638    }
639
640    #[tokio::test]
641    async fn prompts_step_e_key_requests_editor() {
642        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
643        wizard.step = NewAgentStep::Prompts;
644
645        let outcome = wizard.handle_event(&key_event(KeyCode::Char('e'))).await;
646
647        assert!(matches!(outcome, Some(NewAgentAction::EditSystemMd)));
648    }
649
650    #[tokio::test]
651    async fn prompts_step_seeds_system_md_from_draft() {
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.step = NewAgentStep::Prompts;
656        wizard.sync_step_from_draft();
657
658        assert!(wizard.draft.system_md_content.starts_with("# Researcher\n"));
659        assert!(wizard.draft.system_md_content.contains("Research agent"));
660    }
661
662    #[tokio::test]
663    async fn prompts_step_preserves_edited_system_md_on_rerender() {
664        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
665        wizard.draft.entry.name = "Researcher".to_string();
666        wizard.draft.entry.description = "Research agent".to_string();
667        wizard.draft.system_md_content = "# Custom body".to_string();
668        wizard.draft.system_md_edited = true;
669        wizard.step = NewAgentStep::Prompts;
670        wizard.sync_step_from_draft();
671
672        assert_eq!(wizard.draft.system_md_content, "# Custom body");
673    }
674
675    #[tokio::test]
676    async fn model_selector_syncs_to_draft() {
677        let entries = vec![ModelEntry {
678            value: "test:model-a".to_string(),
679            name: "Test / Model A".to_string(),
680            reasoning_levels: vec![ReasoningEffort::Medium, ReasoningEffort::High],
681            supports_image: false,
682            supports_audio: false,
683            disabled_reason: None,
684        }];
685        let selector = ModelSelector::new(entries, "model".to_string(), Some("test:model-a"), Some("high"));
686
687        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
688        wizard.step = NewAgentStep::Model;
689        wizard.model.selector = selector;
690
691        wizard.sync_draft_from_step();
692
693        assert_eq!(wizard.draft.entry.model, "test:model-a");
694        assert_eq!(wizard.draft.entry.reasoning_effort, Some(ReasoningEffort::High));
695    }
696
697    fn make_wizard_with_mcp_configs(mode: NewAgentMode) -> NewAgentWizard {
698        NewAgentWizard::new(mode, vec![], &[PromptFile::Agents], &[McpConfigFile::McpJson])
699    }
700
701    #[tokio::test]
702    async fn tools_tab_moves_focus_to_mcp_configs() {
703        let mut wizard = make_wizard_with_mcp_configs(NewAgentMode::ScaffoldProject);
704        wizard.step = NewAgentStep::Tools;
705
706        assert_eq!(wizard.tools.focus, 0);
707        wizard.handle_event(&key_event(KeyCode::Tab)).await;
708        assert_eq!(wizard.tools.focus, 1);
709    }
710
711    #[tokio::test]
712    async fn tools_back_tab_moves_focus_back_from_mcp_configs() {
713        let mut wizard = make_wizard_with_mcp_configs(NewAgentMode::ScaffoldProject);
714        wizard.step = NewAgentStep::Tools;
715        wizard.tools.focus = 1;
716
717        wizard.handle_event(&key_event(KeyCode::BackTab)).await;
718
719        assert_eq!(wizard.tools.focus, 0);
720        assert_eq!(wizard.step, NewAgentStep::Tools);
721    }
722
723    #[tokio::test]
724    async fn tools_back_tab_on_first_section_goes_to_prev_step() {
725        let mut wizard = make_wizard_with_mcp_configs(NewAgentMode::ScaffoldProject);
726        wizard.step = NewAgentStep::Tools;
727
728        wizard.handle_event(&key_event(KeyCode::BackTab)).await;
729
730        assert_eq!(wizard.step, NewAgentStep::Prompts);
731    }
732
733    #[tokio::test]
734    async fn tools_tab_without_mcp_configs_is_noop() {
735        let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
736        wizard.step = NewAgentStep::Tools;
737
738        wizard.handle_event(&key_event(KeyCode::Tab)).await;
739
740        assert_eq!(wizard.tools.focus, 0);
741        assert_eq!(wizard.step, NewAgentStep::Tools);
742    }
743
744    #[tokio::test]
745    async fn tools_mcp_config_toggle_syncs_to_draft() {
746        let mut wizard = make_wizard_with_mcp_configs(NewAgentMode::ScaffoldProject);
747        wizard.step = NewAgentStep::Tools;
748        wizard.tools.focus = 1;
749
750        wizard.handle_event(&key_event(KeyCode::Char(' '))).await;
751
752        assert!(wizard.draft.workspace_mcp_configs.contains(&"mcp.json".to_string()));
753    }
754
755    #[tokio::test]
756    async fn edit_system_md_with_missing_editor_sets_error() {
757        let (w, h) = (80, 24);
758        let mut wizard = {
759            let mut wizard = make_wizard(NewAgentMode::ScaffoldProject);
760            wizard.step = NewAgentStep::Prompts;
761            wizard.draft.system_md_content = "original content".to_string();
762            wizard
763        };
764
765        let mut terminal = TerminalRuntime::headless(Vec::<u8>::new(), (w, h));
766        let result = edit_system_md(&mut wizard, &mut terminal, "__nonexistent_editor__").await;
767
768        assert!(result.is_ok(), "edit_system_md should not return an error");
769        assert_eq!(wizard.draft.system_md_content, "original content", "content should be unchanged");
770        assert!(!wizard.draft.system_md_edited, "edited flag should remain false");
771        assert!(wizard.editor_error.is_some(), "editor_error should be set");
772        assert!(wizard.editor_error.as_ref().unwrap().contains("__nonexistent_editor__"));
773
774        let ctx = ViewContext::new((w, h));
775        let lines = wizard.render_body(&ctx, w);
776        let text: Vec<String> = lines.iter().map(Line::plain_text).collect();
777        assert!(
778            text.iter().any(|l| l.contains("Could not open editor")),
779            "expected editor error in rendered output, got: {text:?}"
780        );
781    }
782}