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(¤t_idx) {
208 Ordering::Less => StepVisualState::Complete,
209 Ordering::Equal => StepVisualState::Current,
210 Ordering::Greater => StepVisualState::Upcoming,
211 },
212 })
213 .collect();
214 let stepper = Stepper { items: &items, separator: " \u{2500} ", leading_padding: 2 };
215
216 Frame::new(vec![
217 Line::styled(format!(" {title}"), ctx.theme.primary()),
218 Line::new(String::new()),
219 stepper.render(ctx),
220 Line::new(String::new()),
221 ])
222 }
223
224 fn render_footer(&self, ctx: &ViewContext) -> Frame {
225 let forward = if matches!(self.step, NewAgentStep::Tools) { "finish" } else { "next" };
226 let show_tab = matches!(self.step, NewAgentStep::Identity)
227 || (matches!(self.step, NewAgentStep::Tools) && self.tools.has_multiple_sections());
228 let tab_hint = if show_tab { "[tab] field " } else { "" };
229 let reasoning = if matches!(self.step, NewAgentStep::Model) { " [\u{2190}\u{2192}] reasoning" } else { "" };
230 let keys = format!(
231 " [enter] {forward} {tab_hint}[shift+tab] back [space] toggle [\u{2191}\u{2193}] move{reasoning} [esc] cancel"
232 );
233 Frame::new(vec![Line::new(String::new()), Line::styled(keys, ctx.theme.muted())])
234 }
235
236 fn render_body(&mut self, ctx: &ViewContext, pane_w: u16) -> Vec<Line> {
237 let mut lines = Vec::new();
238 lines.push(Line::with_style(format!(" {}", self.step.heading()), Style::fg(ctx.theme.heading()).bold()));
239 lines.push(Line::new(String::new()));
240
241 match self.step {
242 NewAgentStep::Identity => {
243 lines.extend(self.identity.render(ctx, pane_w, &self.draft, &self.mode));
244 }
245 NewAgentStep::Model => {
246 lines.extend(self.model.render(ctx));
247 }
248 NewAgentStep::Prompts => {
249 let system_md_path = self.draft.generated_paths(&self.mode).system_md.display().to_string();
250 lines.extend(self.prompts.render(ctx, pane_w, &self.draft.system_md_content, &system_md_path));
251 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}