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