1use ratatui::{
10 Frame,
11 layout::{Constraint, Direction, Layout, Rect},
12 style::{Modifier, Style},
13 text::{Line, Span},
14 widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
15};
16
17use super::catalog::{self, Credential};
18use super::state::{FieldValue, Step, Wizard};
19use crate::tui::theme::*;
20use crate::tui::widgets::footer::{Hint, draw_hint_bar, hint};
21use crate::tui::widgets::help::{HelpSection, draw_help};
22
23pub fn draw(frame: &mut Frame, wizard: &Wizard) {
25 let chunks = Layout::default()
26 .direction(Direction::Vertical)
27 .constraints([
28 Constraint::Length(3),
29 Constraint::Min(5),
30 Constraint::Length(3),
31 ])
32 .split(frame.area());
33
34 draw_header(frame, chunks[0], wizard);
35 draw_body(frame, chunks[1], wizard);
36 draw_footer(frame, chunks[2], wizard);
37
38 if let Some(pending) = &wizard.confirm {
39 pending.dialog.draw(frame, frame.area());
40 } else if wizard.show_help {
41 draw_help(frame, frame.area(), &help_sections());
42 }
43}
44
45fn help_sections() -> [HelpSection; 3] {
47 [
48 HelpSection {
49 title: "Navigate",
50 entries: vec![
51 ("↑ ↓ / k j", "move"),
52 ("← → / h l", "change a choice"),
53 ("space", "select / toggle"),
54 ("enter", "act on the focused row; Continue moves on"),
55 ("tab", "next screen"),
56 ("shift-tab / esc", "previous screen"),
57 ],
58 },
59 HelpSection {
60 title: "Providers",
61 entries: vec![
62 ("v", "re-check a credential"),
63 ("o", "open a signup page"),
64 ("ctrl-r", "show or hide credentials"),
65 ],
66 },
67 HelpSection {
68 title: "Finish",
69 entries: vec![
70 ("ctrl-s", "write and finish, from anywhere"),
71 (
72 "q / ctrl-c",
73 "quit without writing (asks if you changed things)",
74 ),
75 ],
76 },
77 ]
78}
79
80fn continue_line(wizard: &Wizard) -> Line<'static> {
82 let focused = wizard.on_continue();
83 let style = if focused {
84 Style::default()
85 .fg(C_ACCENT)
86 .add_modifier(Modifier::BOLD | Modifier::REVERSED)
87 } else {
88 Style::default().fg(C_MUTED)
89 };
90 Line::from(vec![
91 Span::styled(
92 if focused { "› " } else { " " },
93 Style::default().fg(C_ACCENT),
94 ),
95 Span::styled(format!("[ {} ]", wizard.continue_label()), style),
96 ])
97}
98
99fn draw_header(frame: &mut Frame, area: Rect, wizard: &Wizard) {
101 let current = wizard.step.index();
102 let mut spans = vec![Span::styled(
103 "Leviath setup ",
104 Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD),
105 )];
106 for (index, step) in Step::ALL.iter().enumerate() {
107 if index > 0 {
108 spans.push(Span::styled(" › ", Style::default().fg(C_DIM)));
109 }
110 let style = if index == current {
111 Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD)
112 } else if index < current {
113 Style::default().fg(C_SUCCESS)
114 } else {
115 Style::default().fg(C_DIM)
116 };
117 spans.push(Span::styled(step.title(), style));
118 }
119
120 frame.render_widget(
121 Paragraph::new(Line::from(spans)).block(
122 Block::default()
123 .borders(Borders::ALL)
124 .border_style(Style::default().fg(C_BORDER)),
125 ),
126 area,
127 );
128}
129
130fn draw_body(frame: &mut Frame, area: Rect, wizard: &Wizard) {
132 let block = Block::default()
133 .borders(Borders::ALL)
134 .border_style(Style::default().fg(C_BORDER_FOCUS))
135 .title(format!(" {} ", wizard.step.title()));
136 let inner = block.inner(area);
137 frame.render_widget(block, area);
138
139 match wizard.step {
140 Step::Welcome => draw_welcome(frame, inner, wizard),
141 Step::Providers => draw_providers(frame, inner, wizard),
142 Step::ProviderDetail => draw_provider_detail(frame, inner, wizard),
143 Step::Defaults | Step::Limits => draw_fields(frame, inner, wizard),
144 Step::Agents => draw_agents(frame, inner, wizard),
145 Step::Mcp => draw_mcp(frame, inner, wizard),
146 Step::Review => draw_review(frame, inner, wizard),
147 }
148}
149
150fn draw_welcome(frame: &mut Frame, area: Rect, wizard: &Wizard) {
151 let configured: Vec<&str> = wizard
152 .providers
153 .iter()
154 .filter(|r| r.selected)
155 .map(|r| r.provider.display)
156 .collect();
157 let pending = wizard.agents.iter().filter(|r| r.selected).count();
158
159 let mut lines = vec![
160 Line::from(Span::styled(
161 "This sets up providers, defaults, the bundled agents, and any MCP",
162 Style::default().fg(C_WHITE),
163 )),
164 Line::from(Span::styled(
165 "servers you already have configured in other tools.",
166 Style::default().fg(C_WHITE),
167 )),
168 Line::from(""),
169 ];
170
171 if configured.is_empty() {
172 lines.push(Line::from(Span::styled(
173 "Nothing is configured yet.",
174 Style::default().fg(C_MUTED),
175 )));
176 } else {
177 lines.push(Line::from(vec![
178 Span::styled("Already configured: ", Style::default().fg(C_MUTED)),
179 Span::styled(configured.join(", "), Style::default().fg(C_SUCCESS)),
180 ]));
181 }
182 lines.push(Line::from(vec![
183 Span::styled("Blueprints to install: ", Style::default().fg(C_MUTED)),
184 Span::styled(pending.to_string(), Style::default().fg(C_WHITE)),
185 ]));
186 if !wizard.mcp.is_empty() {
187 lines.push(Line::from(vec![
188 Span::styled(
189 "MCP servers found elsewhere: ",
190 Style::default().fg(C_MUTED),
191 ),
192 Span::styled(wizard.mcp.len().to_string(), Style::default().fg(C_WHITE)),
193 ]));
194 }
195 lines.push(Line::from(""));
196 lines.push(Line::from(Span::styled(
197 "Nothing is written until the last screen.",
198 Style::default().fg(C_DIM),
199 )));
200 lines.push(Line::from(""));
201 lines.push(continue_line(wizard));
202
203 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
204}
205
206fn wrap_plain(text: &str, width: usize) -> Vec<String> {
210 let width = width.max(1);
211 let mut lines = Vec::new();
212 let mut current = String::new();
213 for word in text.split_whitespace() {
214 let mut word = word;
215 loop {
216 let need = if current.is_empty() {
217 word.chars().count()
218 } else {
219 current.chars().count() + 1 + word.chars().count()
220 };
221 if need <= width {
222 if !current.is_empty() {
223 current.push(' ');
224 }
225 current.push_str(word);
226 break;
227 }
228 if current.is_empty() {
229 let cut = word
235 .char_indices()
236 .nth(width)
237 .map(|(i, _)| i)
238 .expect("infallible: the word is longer than width chars");
239 let (head, tail) = word.split_at(cut);
240 lines.push(head.to_string());
241 word = tail;
244 } else {
245 lines.push(std::mem::take(&mut current));
246 }
247 }
248 }
249 if !current.is_empty() {
250 lines.push(current);
251 }
252 lines
253}
254
255fn draw_providers(frame: &mut Frame, area: Rect, wizard: &Wizard) {
256 let mut items: Vec<ListItem> = wizard
257 .providers
258 .iter()
259 .enumerate()
260 .map(|(index, row)| {
261 let mark = if row.selected {
262 GLYPH_COMPLETE
263 } else {
264 GLYPH_PENDING
265 };
266 let mut spans = vec![
267 Span::styled(
268 format!("{mark} "),
269 Style::default().fg(if row.selected { C_SUCCESS } else { C_DIM }),
270 ),
271 Span::styled(row.provider.display, name_style(index == wizard.cursor)),
272 ];
273 if let Some(var) = row.from_env {
274 spans.push(Span::styled(
275 format!(" (${var})"),
276 Style::default().fg(C_WARN),
277 ));
278 } else if !row.value.is_empty() {
279 spans.push(Span::styled(" (set)", Style::default().fg(C_MUTED)));
280 }
281 let mut item_lines = vec![Line::from(spans)];
282 for chunk in wrap_plain(row.provider.blurb, area.width.saturating_sub(6) as usize) {
286 item_lines.push(Line::from(Span::styled(
287 format!(" {chunk}"),
288 Style::default().fg(C_DIM),
289 )));
290 }
291 ListItem::new(item_lines)
292 })
293 .collect();
294 items.push(ListItem::new(vec![Line::from(""), continue_line(wizard)]));
295
296 frame.render_widget(List::new(items), area);
297}
298
299fn draw_provider_detail(frame: &mut Frame, area: Rect, wizard: &Wizard) {
300 let Some(index) = wizard.detail_row() else {
301 frame.render_widget(Paragraph::new(vec![continue_line(wizard)]), area);
303 return;
304 };
305 let row = &wizard.providers[index];
308 let position = wizard.detail + 1;
309 let total = wizard.selected_providers().len();
310
311 let mut lines = vec![
312 Line::from(vec![
313 Span::styled(
314 row.provider.display,
315 Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD),
316 ),
317 Span::styled(
318 format!(" {position} of {total}"),
319 Style::default().fg(C_DIM),
320 ),
321 ]),
322 Line::from(Span::styled(
323 row.provider.blurb,
324 Style::default().fg(C_MUTED),
325 )),
326 Line::from(""),
327 ];
328
329 let row_marker = if wizard.on_continue() { " " } else { "› " };
332 match row.provider.credential {
333 Credential::ApiKey | Credential::BaseUrl => {
334 let label = if row.provider.credential == Credential::ApiKey {
335 "API key"
336 } else {
337 "Base URL"
338 };
339 let mut spans = vec![
340 Span::styled(row_marker, Style::default().fg(C_ACCENT)),
341 Span::styled(format!("{label}: "), Style::default().fg(C_MUTED)),
342 ];
343 match &wizard.edit {
344 Some(edit) if edit.target == super::state::EditTarget::Credential(index) => {
345 spans.extend(edit.line.display_spans(wizard.reveal).spans);
346 }
347 _ => spans.push(Span::styled(
348 credential_display(wizard, index),
349 Style::default().fg(C_WHITE),
350 )),
351 }
352 lines.push(Line::from(spans));
353 if let Some(var) = row.from_env {
354 lines.push(Line::from(Span::styled(
355 format!("Supplied by ${var} - it will not be written to the config."),
356 Style::default().fg(C_WARN),
357 )));
358 }
359 lines.push(Line::from(Span::styled(
360 "Enter to edit. Ctrl-R shows it. o opens the signup page. v re-checks.",
361 Style::default().fg(C_DIM),
362 )));
363 }
364 Credential::None => {
365 lines.push(Line::from(vec![
366 Span::styled(row_marker, Style::default().fg(C_ACCENT)),
367 Span::styled("Reasoning effort: ", Style::default().fg(C_MUTED)),
368 Span::styled(
369 super::state::effort_options()[row.effort],
370 Style::default().fg(C_ACCENT),
371 ),
372 ]));
373 lines.push(Line::from(Span::styled(
374 "← / → to change. Sign in with `claude` if you have not already.",
375 Style::default().fg(C_DIM),
376 )));
377 for warning in [
380 "⚠️ Anthropic's terms prohibit third-party use of subscription auth",
381 " without prior approval. By enabling this transport you accept",
382 " responsibility for compliance with their terms.",
383 " For unambiguous compliance, use a direct Anthropic API key.",
384 ] {
385 lines.push(Line::from(Span::styled(
386 warning,
387 Style::default().fg(C_WARN),
388 )));
389 }
390 }
391 }
392
393 lines.push(Line::from(""));
394 lines.push(status_line(wizard, index));
395 lines.push(Line::from(""));
396 lines.push(continue_line(wizard));
397
398 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
399}
400
401fn credential_display(wizard: &Wizard, index: usize) -> String {
403 let row = &wizard.providers[index];
404 if row.value.is_empty() {
405 return match row.from_env {
406 Some(_) => "(from the environment)".to_string(),
407 None => format!("({})", row.provider.hint),
408 };
409 }
410 if row.provider.credential == Credential::ApiKey && !wizard.reveal {
411 catalog::redact(&row.value)
412 } else {
413 row.value.clone()
414 }
415}
416
417fn status_line(wizard: &Wizard, index: usize) -> Line<'static> {
419 let row = &wizard.providers[index];
420 if row.checking {
421 let frame = SPINNER[(wizard.ticks as usize) % SPINNER.len()];
422 return Line::from(vec![
423 Span::styled(format!("{frame} "), Style::default().fg(C_ACCENT)),
424 Span::styled("checking…", Style::default().fg(C_MUTED)),
425 ]);
426 }
427 match &row.outcome {
428 super::verify::Outcome::Skipped => {
429 Line::from(Span::styled("not checked yet", Style::default().fg(C_DIM)))
430 }
431 super::verify::Outcome::Reachable { .. } => Line::from(vec![
432 Span::styled(format!("{GLYPH_COMPLETE} "), Style::default().fg(C_SUCCESS)),
433 Span::styled(row.outcome.summary(), Style::default().fg(C_SUCCESS)),
434 ]),
435 super::verify::Outcome::Failed { .. } => Line::from(vec![
436 Span::styled(format!("{GLYPH_ERROR} "), Style::default().fg(C_ERROR)),
437 Span::styled(row.outcome.summary(), Style::default().fg(C_ERROR)),
438 ]),
439 }
440}
441
442fn draw_fields(frame: &mut Frame, area: Rect, wizard: &Wizard) {
443 let mut items: Vec<ListItem> = wizard
444 .fields()
445 .iter()
446 .enumerate()
447 .map(|(index, field)| {
448 let selected = index == wizard.cursor;
449 let hint = match &field.value {
450 FieldValue::Bool(_) => "enter/space",
451 FieldValue::Choice { .. } => "enter/← →",
452 _ => "enter",
453 };
454 let mut spans = vec![
455 Span::styled(
456 if selected { "› " } else { " " },
457 Style::default().fg(C_ACCENT),
458 ),
459 Span::styled(format!("{:<28}", field.label), name_style(selected)),
460 ];
461 match &wizard.edit {
462 Some(edit) if edit.target == super::state::EditTarget::Field(index) => {
463 spans.extend(edit.line.display_spans(wizard.reveal).spans);
464 }
465 _ => spans.push(Span::styled(
466 field.value.display(),
467 Style::default().fg(C_ACCENT),
468 )),
469 }
470 spans.push(Span::styled(
471 format!(" [{hint}]"),
472 Style::default().fg(C_DIM),
473 ));
474 ListItem::new(vec![
475 Line::from(spans),
476 Line::from(Span::styled(
477 format!(" {}", field.help),
478 Style::default().fg(C_DIM),
479 )),
480 ])
481 })
482 .collect();
483 items.push(ListItem::new(vec![Line::from(""), continue_line(wizard)]));
484
485 frame.render_widget(List::new(items), area);
486}
487
488fn draw_agents(frame: &mut Frame, area: Rect, wizard: &Wizard) {
489 let mut items: Vec<ListItem> = wizard
490 .agents
491 .iter()
492 .enumerate()
493 .map(|(index, row)| {
494 let mark = if row.selected {
495 GLYPH_COMPLETE
496 } else {
497 GLYPH_PENDING
498 };
499 let action = row.action.label(row.agent.version);
500 let action_style = if row.action.preselect() {
503 Style::default().fg(C_ACCENT)
504 } else {
505 Style::default().fg(C_DIM)
506 };
507 ListItem::new(Line::from(vec![
508 Span::styled(
509 format!("{mark} "),
510 Style::default().fg(if row.selected { C_SUCCESS } else { C_DIM }),
511 ),
512 Span::styled(
513 format!("{:<22}", row.agent.name),
514 name_style(index == wizard.cursor),
515 ),
516 Span::styled(action, action_style),
517 ]))
518 })
519 .collect();
520 items.push(ListItem::new(vec![Line::from(""), continue_line(wizard)]));
521
522 frame.render_widget(List::new(items), area);
523}
524
525fn draw_mcp(frame: &mut Frame, area: Rect, wizard: &Wizard) {
526 let mut items: Vec<ListItem> = wizard
527 .mcp
528 .iter()
529 .enumerate()
530 .map(|(index, row)| {
531 let mark = if row.selected {
532 GLYPH_COMPLETE
533 } else {
534 GLYPH_PENDING
535 };
536 let mut detail = vec![Span::styled(
537 format!(" from {}", row.source),
538 Style::default().fg(C_DIM),
539 )];
540 if !row.candidate.scope.is_empty() {
541 detail.push(Span::styled(
542 format!(" · {}", row.candidate.scope),
543 Style::default().fg(C_DIM),
544 ));
545 }
546 if row.collides {
547 detail.push(Span::styled(
548 format!(" · already configured; would be added as {}", row.name),
549 Style::default().fg(C_WARN),
550 ));
551 }
552 if !row.candidate.inline_secrets.is_empty() {
553 detail.push(Span::styled(
554 format!(
555 " · carries a literal secret in {}",
556 row.candidate.inline_secrets.join(", ")
557 ),
558 Style::default().fg(C_WARN),
559 ));
560 }
561 let endpoint = row
562 .candidate
563 .config
564 .url
565 .clone()
566 .or_else(|| row.candidate.config.command.clone())
567 .unwrap_or_default();
568 ListItem::new(vec![
569 Line::from(vec![
570 Span::styled(
571 format!("{mark} "),
572 Style::default().fg(if row.selected { C_SUCCESS } else { C_DIM }),
573 ),
574 Span::styled(
575 format!("{:<22}", row.candidate.config.name),
576 name_style(index == wizard.cursor),
577 ),
578 Span::styled(endpoint, Style::default().fg(C_MUTED)),
579 ]),
580 Line::from(detail),
581 ])
582 })
583 .collect();
584
585 for error in &wizard.mcp_scan_errors {
586 items.push(ListItem::new(Line::from(Span::styled(
587 format!("{GLYPH_ERROR} {error}"),
588 Style::default().fg(C_WARN),
589 ))));
590 }
591 items.push(ListItem::new(vec![Line::from(""), continue_line(wizard)]));
592
593 frame.render_widget(List::new(items), area);
594}
595
596fn draw_review(frame: &mut Frame, area: Rect, wizard: &Wizard) {
597 let mut lines = vec![Line::from(Span::styled(
598 "About to write:",
599 Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD),
600 ))];
601 for change in wizard.review_lines() {
602 lines.push(Line::from(vec![
603 Span::styled(" • ", Style::default().fg(C_ACCENT)),
604 Span::styled(change, Style::default().fg(C_WHITE)),
605 ]));
606 }
607
608 let secrets = wizard.selected_inline_secrets();
609 if !secrets.is_empty() {
610 lines.push(Line::from(""));
611 lines.push(Line::from(Span::styled(
612 "These imported servers carry a credential written out in full, which",
613 Style::default().fg(C_WARN),
614 )));
615 lines.push(Line::from(Span::styled(
616 "would be copied into your Leviath config:",
617 Style::default().fg(C_WARN),
618 )));
619 for entry in secrets {
620 lines.push(Line::from(Span::styled(
621 format!(" • {entry}"),
622 Style::default().fg(C_WARN),
623 )));
624 }
625 }
626
627 let failures: Vec<String> = wizard
628 .providers
629 .iter()
630 .filter(|r| r.selected && r.outcome.is_failure())
631 .map(|r| format!("{}: {}", r.provider.display, r.outcome.summary()))
632 .collect();
633 if !failures.is_empty() {
634 lines.push(Line::from(""));
635 lines.push(Line::from(Span::styled(
636 "Did not verify (saving anyway is fine):",
637 Style::default().fg(C_ERROR),
638 )));
639 for failure in failures {
640 lines.push(Line::from(Span::styled(
641 format!(" • {failure}"),
642 Style::default().fg(C_ERROR),
643 )));
644 }
645 }
646
647 if wizard
648 .providers
649 .iter()
650 .any(|r| r.selected && r.provider.id == "claude-code")
651 {
652 lines.push(Line::from(""));
653 for warning in [
654 "Claude Code transport: Anthropic's terms may prohibit third-party use of",
655 "subscription auth without prior approval. By enabling it you accept",
656 "responsibility for compliance with their terms.",
657 ] {
658 lines.push(Line::from(Span::styled(
659 warning,
660 Style::default().fg(C_WARN),
661 )));
662 }
663 }
664
665 lines.push(Line::from(""));
666 lines.push(continue_line(wizard));
667
668 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
669}
670
671fn footer_hints(wizard: &Wizard) -> Vec<Hint> {
673 if wizard.confirm.is_some() {
674 return vec![
675 hint("←→", "choose"),
676 hint("enter", "confirm"),
677 hint("esc", "cancel"),
678 ];
679 }
680 if wizard.edit.is_some() {
681 return vec![
682 hint("enter", "save"),
683 hint("esc", "cancel"),
684 hint("←→", "move cursor"),
685 ];
686 }
687 match wizard.step {
688 Step::Welcome => vec![hint("enter", "begin"), hint("?", "help"), hint("q", "quit")],
689 Step::Providers => vec![
690 hint("↑↓", "move"),
691 hint("space/enter", "select"),
692 hint("o", "signup"),
693 hint("v", "check"),
694 hint("tab", "next"),
695 hint("q", "quit"),
696 ],
697 Step::ProviderDetail => vec![
698 hint("enter", "edit"),
699 hint("v", "check"),
700 hint("o", "signup"),
701 hint("tab", "next"),
702 hint("esc", "back"),
703 hint("q", "quit"),
704 ],
705 Step::Defaults | Step::Limits => vec![
706 hint("↑↓", "move"),
707 hint("enter", "change"),
708 hint("←→", "cycle"),
709 hint("tab", "next"),
710 hint("esc", "back"),
711 hint("q", "quit"),
712 ],
713 Step::Agents | Step::Mcp => vec![
714 hint("↑↓", "move"),
715 hint("space/enter", "select"),
716 hint("tab", "next"),
717 hint("esc", "back"),
718 hint("q", "quit"),
719 ],
720 Step::Review => vec![
721 hint("enter", "apply"),
722 hint("v", "re-check"),
723 hint("esc", "back"),
724 hint("q", "quit"),
725 ],
726 }
727}
728
729fn draw_footer(frame: &mut Frame, area: Rect, wizard: &Wizard) {
730 let hints = footer_hints(wizard);
731 let message = wizard.message.as_deref().map(|m| (m, C_WARN));
732 draw_hint_bar(frame, area, message, &hints, true);
733}
734
735fn name_style(selected: bool) -> Style {
737 if selected {
738 Style::default().fg(C_ACTIVE).add_modifier(Modifier::BOLD)
739 } else {
740 Style::default().fg(C_WHITE)
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747 use crate::commands::setup::state::{Edit, EditTarget, FieldValue, Wizard};
748 use crate::config::Config;
749 use crate::tui::TestBackendHarness;
750 use ratatui::Terminal;
751
752 fn rendered(wizard: &Wizard) -> String {
755 let mut terminal = Terminal::new(TestBackendHarness::new(140, 44)).unwrap();
756 terminal.draw(|frame| draw(frame, wizard)).unwrap();
757 terminal.backend().text()
758 }
759
760 #[test]
764 fn narrow_window_wraps_provider_blurbs_instead_of_clipping() {
765 let (_dir, mut w) = wizard();
766 w.enter(Step::Providers);
767 let mut terminal = Terminal::new(TestBackendHarness::new(48, 44)).unwrap();
768 terminal.draw(|frame| draw(frame, &w)).unwrap();
769 let screen = terminal.backend().text();
770 assert!(
771 screen.contains("disabled"),
772 "the blurb tail must survive a 48-column window:\n{screen}"
773 );
774 }
775
776 #[test]
777 fn wrap_plain_wraps_at_word_boundaries_and_hard_breaks_long_words() {
778 assert_eq!(
779 wrap_plain("alpha beta gamma", 11),
780 vec!["alpha beta", "gamma"]
781 );
782 assert_eq!(wrap_plain("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
784 assert_eq!(
786 wrap_plain("\u{65e5}\u{672c}\u{8a9e}\u{3067}\u{3059}", 2),
787 vec!["\u{65e5}\u{672c}", "\u{8a9e}\u{3067}", "\u{3059}"]
788 );
789 assert!(wrap_plain("", 10).is_empty());
790 assert_eq!(wrap_plain("ab", 0), vec!["a", "b"]);
792 }
793
794 fn wizard() -> (tempfile::TempDir, Wizard) {
795 let dir = tempfile::tempdir().unwrap();
796 let wizard = crate::commands::setup::state::tests::test_wizard(dir.path());
797 (dir, wizard)
798 }
799
800 #[test]
801 fn every_step_draws_without_panicking_and_names_itself() {
802 let dir = tempfile::tempdir().unwrap();
805 let mut w = Wizard::new(
806 Config::default(),
807 &|_| None,
808 vec![(
809 "Claude Code".to_string(),
810 crate::commands::setup::import::Candidate {
811 config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
812 scope: "/repo".to_string(),
813 inline_secrets: vec!["API_TOKEN".to_string()],
814 },
815 )],
816 vec!["Zed: unreadable".to_string()],
817 dir.path(),
818 std::sync::Arc::new(|_| true),
819 );
820 w.providers[0].selected = true;
821
822 for step in Step::ALL {
823 w.enter(step);
824 let screen = rendered(&w);
825 assert!(
826 screen.contains(step.title()),
827 "{step:?} did not name itself:\n{screen}"
828 );
829 }
830 }
831
832 #[test]
833 fn a_tiny_terminal_still_draws() {
834 let (_dir, w) = wizard();
836 let mut terminal = Terminal::new(TestBackendHarness::new(20, 8)).unwrap();
837
838 assert!(terminal.draw(|frame| draw(frame, &w)).is_ok());
839 }
840
841 #[test]
842 fn the_welcome_screen_reports_what_is_already_there() {
843 let (_dir, mut w) = wizard();
844 assert!(rendered(&w).contains("Nothing is configured yet"));
845
846 w.providers[0].selected = true;
847 let screen = rendered(&w);
848 assert!(screen.contains("Already configured"));
849 assert!(screen.contains("Anthropic"));
850 }
851
852 #[test]
853 fn the_provider_list_marks_where_each_credential_came_from() {
854 let dir = tempfile::tempdir().unwrap();
855 let mut w = Wizard::new(
856 Config::default(),
857 &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
858 Vec::new(),
859 Vec::new(),
860 dir.path(),
861 std::sync::Arc::new(|_| true),
862 );
863 w.providers[1].selected = true;
864 w.providers[1].value = "sk-oai".to_string();
865 w.enter(Step::Providers);
866
867 let screen = rendered(&w);
868 assert!(
869 screen.contains("$ANTHROPIC_API_KEY"),
870 "the environment source must be visible:\n{screen}"
871 );
872 assert!(screen.contains("(set)"), "{screen}");
873 assert!(!screen.contains("sk-oai"), "a key leaked:\n{screen}");
874 }
875
876 #[test]
877 fn a_stored_key_is_redacted_until_ctrl_r() {
878 let (_dir, mut w) = wizard();
879 w.providers[0].selected = true;
880 w.providers[0].value = "sk-ant-secret-value-here".to_string();
881 w.enter(Step::ProviderDetail);
882
883 let hidden = rendered(&w);
884 assert!(hidden.contains("****here"), "{hidden}");
886 assert!(
887 !hidden.contains("sk-ant-s"),
888 "issuer prefix leaked:\n{hidden}"
889 );
890 assert!(
891 !hidden.contains("secret-value"),
892 "the key leaked:\n{hidden}"
893 );
894
895 w.reveal = true;
896 assert!(rendered(&w).contains("sk-ant-secret-value-here"));
897 }
898
899 #[test]
900 fn a_key_being_typed_is_masked_until_revealed() {
901 let (_dir, mut w) = wizard();
902 w.providers[0].selected = true;
903 w.enter(Step::ProviderDetail);
904 w.edit = Some(Edit {
905 target: EditTarget::Credential(0),
906 line: crate::tui::widgets::line_edit::LineEdit::new("sk-typing".to_string(), true),
907 });
908
909 let hidden = rendered(&w);
910 assert!(hidden.contains("•••"), "{hidden}");
911 assert!(!hidden.contains("sk-typing"), "the key leaked:\n{hidden}");
912
913 w.reveal = true;
914 assert!(rendered(&w).contains("sk-typing"));
915 }
916
917 #[test]
918 fn an_empty_credential_shows_its_placeholder_or_its_source() {
919 let (_dir, mut w) = wizard();
920 w.providers[0].selected = true;
921 w.enter(Step::ProviderDetail);
922 assert!(rendered(&w).contains("sk-ant-..."));
923
924 w.providers[0].from_env = Some("ANTHROPIC_API_KEY");
925 let screen = rendered(&w);
926 assert!(screen.contains("from the environment"));
927 assert!(
928 screen.contains("will not be written"),
929 "the user must know the key stays where they put it:\n{screen}"
930 );
931 }
932
933 #[test]
934 fn a_base_url_is_never_masked() {
935 let (_dir, mut w) = wizard();
937 let ollama = w
938 .providers
939 .iter()
940 .position(|r| r.provider.id == "ollama")
941 .expect("ollama is offered");
942 w.providers[ollama].selected = true;
943 w.providers[ollama].value = "http://box:11434".to_string();
944 w.enter(Step::ProviderDetail);
945
946 assert!(rendered(&w).contains("http://box:11434"));
947 }
948
949 #[test]
950 fn every_verification_state_is_drawn_distinctly() {
951 let (_dir, mut w) = wizard();
952 w.providers[0].selected = true;
953 w.providers[0].value = "sk-ant".to_string();
954 w.enter(Step::ProviderDetail);
955
956 assert!(rendered(&w).contains("not checked yet"));
957
958 w.providers[0].checking = true;
959 assert!(rendered(&w).contains("checking"));
960
961 w.providers[0].checking = false;
962 w.providers[0].outcome = crate::commands::setup::verify::Outcome::Reachable {
963 models: vec!["a".into(), "b".into()],
964 };
965 assert!(rendered(&w).contains("2 models"));
966
967 w.providers[0].outcome = crate::commands::setup::verify::Outcome::Failed {
968 message: "rejected - check the key".into(),
969 };
970 assert!(rendered(&w).contains("rejected"));
971 }
972
973 #[test]
974 fn the_claude_code_card_shows_its_effort_and_its_caveat() {
975 let (_dir, mut w) = wizard();
976 let index = w
977 .providers
978 .iter()
979 .position(|r| r.provider.id == "claude-code")
980 .expect("the transport is offered");
981 w.providers[index].selected = true;
982 w.enter(Step::ProviderDetail);
983
984 let screen = rendered(&w);
985 assert!(screen.contains("Reasoning effort"));
986 assert!(
987 screen.contains("email"),
988 "the privacy cost must be on screen"
989 );
990 assert!(
991 screen.contains("terms"),
992 "the terms-of-service risk must be on screen:\n{screen}"
993 );
994 }
995
996 #[test]
997 fn the_review_screen_warns_about_the_claude_code_terms() {
998 let (_dir, mut w) = wizard();
999 let index = w
1000 .providers
1001 .iter()
1002 .position(|r| r.provider.id == "claude-code")
1003 .expect("the transport is offered");
1004 w.enter(Step::Review);
1005 assert!(
1006 !rendered(&w).contains("Claude Code transport: Anthropic"),
1007 "the warning is only for a setup that enables it"
1008 );
1009
1010 w.providers[index].selected = true;
1011 let screen = rendered(&w);
1012 assert!(
1013 screen.contains("Claude Code transport: Anthropic"),
1014 "{screen}"
1015 );
1016 assert!(screen.contains("responsibility for compliance"), "{screen}");
1017 }
1018
1019 #[test]
1020 fn the_tos_confirmation_dialog_draws_over_the_review_screen() {
1021 let (_dir, mut w) = wizard();
1022 let index = w
1023 .providers
1024 .iter()
1025 .position(|r| r.provider.id == "claude-code")
1026 .expect("the transport is offered");
1027 w.providers[index].selected = true;
1028 w.enter(Step::Review);
1029 w.open_tos_confirm();
1030
1031 let screen = rendered(&w);
1032 assert!(
1033 screen.contains("terms of service"),
1034 "dialog title missing:\n{screen}"
1035 );
1036 assert!(
1037 screen.contains("[ Accept and save ]"),
1038 "the affirmative button is missing:\n{screen}"
1039 );
1040 assert!(
1041 screen.contains("[ Cancel ]"),
1042 "the safe button is missing:\n{screen}"
1043 );
1044 }
1045
1046 #[test]
1047 fn the_quit_and_no_provider_dialogs_draw_their_buttons() {
1048 let (_dir, mut w) = wizard();
1049 w.open_quit_confirm();
1050 let screen = rendered(&w);
1051 assert!(screen.contains("Quit setup?"), "{screen}");
1052 assert!(screen.contains("[ Stay ]"), "{screen}");
1053
1054 w.confirm = None;
1055 w.open_no_providers_confirm();
1056 let screen = rendered(&w);
1057 assert!(screen.contains("No providers selected"), "{screen}");
1058 assert!(screen.contains("[ Go back ]"), "{screen}");
1059 assert!(screen.contains("[ Continue anyway ]"), "{screen}");
1060 }
1061
1062 #[test]
1063 fn the_credential_screen_draws_nothing_when_no_provider_is_selected() {
1064 let (_dir, mut w) = wizard();
1065 w.enter(Step::ProviderDetail);
1066
1067 assert!(rendered(&w).contains("Credentials"));
1069 }
1070
1071 #[test]
1072 fn the_credential_screen_moves_the_focus_marker_onto_the_continue_button() {
1073 let (_dir, mut w) = wizard();
1074 w.providers[0].selected = true;
1075 w.enter(Step::ProviderDetail);
1076
1077 assert!(rendered(&w).contains("› API key:"));
1079
1080 w.cursor = w.row_count();
1082 let screen = rendered(&w);
1083 assert!(!screen.contains("› API key:"), "{screen}");
1084 assert!(screen.contains("› [ Continue: Defaults ]"), "{screen}");
1085 }
1086
1087 #[test]
1088 fn a_field_being_edited_shows_the_buffer_not_the_stored_value() {
1089 let (_dir, mut w) = wizard();
1090 w.enter(Step::Limits);
1091 w.edit = Some(Edit {
1092 target: EditTarget::Field(0),
1093 line: crate::tui::widgets::line_edit::LineEdit::new("42".to_string(), false),
1094 });
1095
1096 assert!(rendered(&w).contains("42"));
1097 }
1098
1099 #[test]
1100 fn each_field_kind_advertises_the_key_that_changes_it() {
1101 let (_dir, mut w) = wizard();
1102 w.enter(Step::Limits);
1103 let screen = rendered(&w);
1104 assert!(screen.contains("[enter]"), "numbers are typed");
1105 assert!(screen.contains("[enter/space]"), "booleans are toggled");
1106
1107 w.providers[0].selected = true;
1108 w.enter(Step::Defaults);
1109 assert!(rendered(&w).contains("enter/← →"), "choices are cycled");
1110 }
1111
1112 #[test]
1113 fn the_agent_list_shows_what_each_row_would_do() {
1114 let dir = tempfile::tempdir().unwrap();
1115 crate::bundled::install_bundled(&crate::bundled::BUNDLED_AGENTS[0], dir.path()).unwrap();
1116 let mut w = crate::commands::setup::state::tests::test_wizard(dir.path());
1117 w.enter(Step::Agents);
1118
1119 let screen = rendered(&w);
1120 assert!(screen.contains("up to date"));
1121 let not_installed = &crate::bundled::BUNDLED_AGENTS[1];
1124 assert!(screen.contains(&format!("install {}", not_installed.version)));
1125 }
1126
1127 #[test]
1128 fn the_mcp_list_flags_collisions_scopes_and_inline_secrets() {
1129 let dir = tempfile::tempdir().unwrap();
1130 let base = Config {
1131 mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![])],
1132 ..Config::default()
1133 };
1134 let mut candidate = crate::commands::setup::import::Candidate {
1135 config: leviath_mcp::MCPServerConfig::http("fs", "https://x.test/mcp"),
1136 scope: "/repo".to_string(),
1137 inline_secrets: vec!["Authorization".to_string()],
1138 };
1139 candidate.config.name = "fs".to_string();
1140 let mut w = Wizard::new(
1141 base,
1142 &|_| None,
1143 vec![("Cursor".to_string(), candidate)],
1144 vec!["Zed: couldn't parse this".to_string()],
1145 dir.path(),
1146 std::sync::Arc::new(|_| true),
1147 );
1148 w.enter(Step::Mcp);
1149
1150 let screen = rendered(&w);
1151 assert!(screen.contains("from Cursor"));
1152 assert!(screen.contains("/repo"));
1153 assert!(screen.contains("already configured"));
1154 assert!(screen.contains("fs-2"), "the free name is shown");
1155 assert!(screen.contains("literal secret"));
1156 assert!(screen.contains("Zed"), "the unreadable source is reported");
1157 assert!(screen.contains("https://x.test/mcp"));
1158 }
1159
1160 #[test]
1161 fn an_imported_stdio_server_shows_its_command() {
1162 let dir = tempfile::tempdir().unwrap();
1163 let mut w = Wizard::new(
1164 Config::default(),
1165 &|_| None,
1166 vec![(
1167 "Codex".to_string(),
1168 crate::commands::setup::import::Candidate {
1169 config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
1170 scope: String::new(),
1171 inline_secrets: Vec::new(),
1172 },
1173 )],
1174 Vec::new(),
1175 dir.path(),
1176 std::sync::Arc::new(|_| true),
1177 );
1178 w.enter(Step::Mcp);
1179
1180 assert!(rendered(&w).contains("npx"));
1181 }
1182
1183 #[test]
1184 fn the_review_screen_lists_changes_and_both_kinds_of_warning() {
1185 let dir = tempfile::tempdir().unwrap();
1186 let mut w = Wizard::new(
1187 Config::default(),
1188 &|_| None,
1189 vec![(
1190 "Cursor".to_string(),
1191 crate::commands::setup::import::Candidate {
1192 config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
1193 scope: String::new(),
1194 inline_secrets: vec!["API_TOKEN".to_string()],
1195 },
1196 )],
1197 Vec::new(),
1198 dir.path(),
1199 std::sync::Arc::new(|_| true),
1200 );
1201 w.providers[0].selected = true;
1202 w.providers[0].value = "sk-ant-x".to_string();
1203 w.providers[0].outcome = crate::commands::setup::verify::Outcome::Failed {
1204 message: "rejected - check the key".into(),
1205 };
1206 w.enter(Step::Review);
1207
1208 let screen = rendered(&w);
1209 assert!(screen.contains("credential set"));
1210 assert!(screen.contains("written out in full"), "secret warning");
1211 assert!(screen.contains("API_TOKEN"));
1212 assert!(screen.contains("Did not verify"));
1213 assert!(
1214 screen.contains("saving anyway is fine"),
1215 "a failed check must not read as a blocker"
1216 );
1217 }
1218
1219 #[test]
1220 fn the_review_screen_says_when_nothing_would_change() {
1221 let (_dir, mut w) = wizard();
1222 for row in w.agents.iter_mut() {
1223 row.selected = false;
1224 }
1225 w.enter(Step::Review);
1226
1227 assert!(rendered(&w).contains("Nothing would change"));
1228 }
1229
1230 #[test]
1231 fn the_footer_shows_a_message_and_the_right_hints_per_screen() {
1232 let (_dir, mut w) = wizard();
1233 w.message = Some("Credentials shown.".to_string());
1234 assert!(rendered(&w).contains("Credentials shown."));
1235
1236 w.message = None;
1237 for (step, expected) in [
1238 (Step::Welcome, "enter begin"),
1239 (Step::Providers, "space/enter select"),
1240 (Step::ProviderDetail, "enter edit"),
1241 (Step::Defaults, "enter change"),
1242 (Step::Limits, "enter change"),
1243 (Step::Agents, "space/enter select"),
1244 (Step::Mcp, "space/enter select"),
1245 (Step::Review, "enter apply"),
1246 ] {
1247 w.enter(step);
1248 let screen = rendered(&w);
1249 assert!(
1250 screen.contains(expected),
1251 "{step:?} footer missing {expected:?}:\n{screen}"
1252 );
1253 }
1254
1255 w.edit = Some(Edit {
1256 target: EditTarget::Field(0),
1257 line: crate::tui::widgets::line_edit::LineEdit::new(String::new(), false),
1258 });
1259 assert!(rendered(&w).contains("esc cancel"));
1260
1261 w.edit = None;
1263 w.open_quit_confirm();
1264 assert!(rendered(&w).contains("enter confirm"));
1265 }
1266
1267 #[test]
1268 fn the_help_overlay_lists_the_bindings() {
1269 let (_dir, mut w) = wizard();
1270 w.show_help = true;
1271
1272 let screen = rendered(&w);
1273 assert!(screen.contains("Help"));
1274 assert!(screen.contains("ctrl-s"));
1275 assert!(screen.contains("ctrl-r"));
1276 }
1277
1278 #[test]
1279 fn the_breadcrumb_marks_done_current_and_upcoming_steps() {
1280 let (_dir, mut w) = wizard();
1281 w.enter(Step::Agents);
1282
1283 let screen = rendered(&w);
1284 for step in Step::ALL {
1285 assert!(
1286 screen.contains(step.title()),
1287 "{step:?} missing from header"
1288 );
1289 }
1290 }
1291
1292 #[test]
1293 fn a_choice_field_with_an_out_of_range_index_draws_a_placeholder() {
1294 let (_dir, mut w) = wizard();
1295 w.enter(Step::Defaults);
1296 w.defaults[0].value = FieldValue::Choice {
1297 options: vec!["a".to_string()],
1298 index: 9,
1299 };
1300
1301 assert!(rendered(&w).contains("(none)"));
1302 }
1303}