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.is_change() {
501 Style::default().fg(C_ACCENT)
502 } else {
503 Style::default().fg(C_DIM)
504 };
505 ListItem::new(Line::from(vec![
506 Span::styled(
507 format!("{mark} "),
508 Style::default().fg(if row.selected { C_SUCCESS } else { C_DIM }),
509 ),
510 Span::styled(
511 format!("{:<22}", row.agent.name),
512 name_style(index == wizard.cursor),
513 ),
514 Span::styled(action, action_style),
515 ]))
516 })
517 .collect();
518 items.push(ListItem::new(vec![Line::from(""), continue_line(wizard)]));
519
520 frame.render_widget(List::new(items), area);
521}
522
523fn draw_mcp(frame: &mut Frame, area: Rect, wizard: &Wizard) {
524 let mut items: Vec<ListItem> = wizard
525 .mcp
526 .iter()
527 .enumerate()
528 .map(|(index, row)| {
529 let mark = if row.selected {
530 GLYPH_COMPLETE
531 } else {
532 GLYPH_PENDING
533 };
534 let mut detail = vec![Span::styled(
535 format!(" from {}", row.source),
536 Style::default().fg(C_DIM),
537 )];
538 if !row.candidate.scope.is_empty() {
539 detail.push(Span::styled(
540 format!(" · {}", row.candidate.scope),
541 Style::default().fg(C_DIM),
542 ));
543 }
544 if row.collides {
545 detail.push(Span::styled(
546 format!(" · already configured; would be added as {}", row.name),
547 Style::default().fg(C_WARN),
548 ));
549 }
550 if !row.candidate.inline_secrets.is_empty() {
551 detail.push(Span::styled(
552 format!(
553 " · carries a literal secret in {}",
554 row.candidate.inline_secrets.join(", ")
555 ),
556 Style::default().fg(C_WARN),
557 ));
558 }
559 let endpoint = row
560 .candidate
561 .config
562 .url
563 .clone()
564 .or_else(|| row.candidate.config.command.clone())
565 .unwrap_or_default();
566 ListItem::new(vec![
567 Line::from(vec![
568 Span::styled(
569 format!("{mark} "),
570 Style::default().fg(if row.selected { C_SUCCESS } else { C_DIM }),
571 ),
572 Span::styled(
573 format!("{:<22}", row.candidate.config.name),
574 name_style(index == wizard.cursor),
575 ),
576 Span::styled(endpoint, Style::default().fg(C_MUTED)),
577 ]),
578 Line::from(detail),
579 ])
580 })
581 .collect();
582
583 for error in &wizard.mcp_scan_errors {
584 items.push(ListItem::new(Line::from(Span::styled(
585 format!("{GLYPH_ERROR} {error}"),
586 Style::default().fg(C_WARN),
587 ))));
588 }
589 items.push(ListItem::new(vec![Line::from(""), continue_line(wizard)]));
590
591 frame.render_widget(List::new(items), area);
592}
593
594fn draw_review(frame: &mut Frame, area: Rect, wizard: &Wizard) {
595 let mut lines = vec![Line::from(Span::styled(
596 "About to write:",
597 Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD),
598 ))];
599 for change in wizard.review_lines() {
600 lines.push(Line::from(vec![
601 Span::styled(" • ", Style::default().fg(C_ACCENT)),
602 Span::styled(change, Style::default().fg(C_WHITE)),
603 ]));
604 }
605
606 let secrets = wizard.selected_inline_secrets();
607 if !secrets.is_empty() {
608 lines.push(Line::from(""));
609 lines.push(Line::from(Span::styled(
610 "These imported servers carry a credential written out in full, which",
611 Style::default().fg(C_WARN),
612 )));
613 lines.push(Line::from(Span::styled(
614 "would be copied into your Leviath config:",
615 Style::default().fg(C_WARN),
616 )));
617 for entry in secrets {
618 lines.push(Line::from(Span::styled(
619 format!(" • {entry}"),
620 Style::default().fg(C_WARN),
621 )));
622 }
623 }
624
625 let failures: Vec<String> = wizard
626 .providers
627 .iter()
628 .filter(|r| r.selected && r.outcome.is_failure())
629 .map(|r| format!("{}: {}", r.provider.display, r.outcome.summary()))
630 .collect();
631 if !failures.is_empty() {
632 lines.push(Line::from(""));
633 lines.push(Line::from(Span::styled(
634 "Did not verify (saving anyway is fine):",
635 Style::default().fg(C_ERROR),
636 )));
637 for failure in failures {
638 lines.push(Line::from(Span::styled(
639 format!(" • {failure}"),
640 Style::default().fg(C_ERROR),
641 )));
642 }
643 }
644
645 if wizard
646 .providers
647 .iter()
648 .any(|r| r.selected && r.provider.id == "claude-code")
649 {
650 lines.push(Line::from(""));
651 for warning in [
652 "Claude Code transport: Anthropic's terms may prohibit third-party use of",
653 "subscription auth without prior approval. By enabling it you accept",
654 "responsibility for compliance with their terms.",
655 ] {
656 lines.push(Line::from(Span::styled(
657 warning,
658 Style::default().fg(C_WARN),
659 )));
660 }
661 }
662
663 lines.push(Line::from(""));
664 lines.push(continue_line(wizard));
665
666 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
667}
668
669fn footer_hints(wizard: &Wizard) -> Vec<Hint> {
671 if wizard.confirm.is_some() {
672 return vec![
673 hint("←→", "choose"),
674 hint("enter", "confirm"),
675 hint("esc", "cancel"),
676 ];
677 }
678 if wizard.edit.is_some() {
679 return vec![
680 hint("enter", "save"),
681 hint("esc", "cancel"),
682 hint("←→", "move cursor"),
683 ];
684 }
685 match wizard.step {
686 Step::Welcome => vec![hint("enter", "begin"), hint("?", "help"), hint("q", "quit")],
687 Step::Providers => vec![
688 hint("↑↓", "move"),
689 hint("space/enter", "select"),
690 hint("o", "signup"),
691 hint("v", "check"),
692 hint("tab", "next"),
693 hint("q", "quit"),
694 ],
695 Step::ProviderDetail => vec![
696 hint("enter", "edit"),
697 hint("v", "check"),
698 hint("o", "signup"),
699 hint("tab", "next"),
700 hint("esc", "back"),
701 hint("q", "quit"),
702 ],
703 Step::Defaults | Step::Limits => vec![
704 hint("↑↓", "move"),
705 hint("enter", "change"),
706 hint("←→", "cycle"),
707 hint("tab", "next"),
708 hint("esc", "back"),
709 hint("q", "quit"),
710 ],
711 Step::Agents | Step::Mcp => vec![
712 hint("↑↓", "move"),
713 hint("space/enter", "select"),
714 hint("tab", "next"),
715 hint("esc", "back"),
716 hint("q", "quit"),
717 ],
718 Step::Review => vec![
719 hint("enter", "apply"),
720 hint("v", "re-check"),
721 hint("esc", "back"),
722 hint("q", "quit"),
723 ],
724 }
725}
726
727fn draw_footer(frame: &mut Frame, area: Rect, wizard: &Wizard) {
728 let hints = footer_hints(wizard);
729 let message = wizard.message.as_deref().map(|m| (m, C_WARN));
730 draw_hint_bar(frame, area, message, &hints, true);
731}
732
733fn name_style(selected: bool) -> Style {
735 if selected {
736 Style::default().fg(C_ACTIVE).add_modifier(Modifier::BOLD)
737 } else {
738 Style::default().fg(C_WHITE)
739 }
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745 use crate::commands::setup::state::{Edit, EditTarget, FieldValue, Wizard};
746 use crate::config::Config;
747 use crate::tui::TestBackendHarness;
748 use ratatui::Terminal;
749
750 fn rendered(wizard: &Wizard) -> String {
753 let mut terminal = Terminal::new(TestBackendHarness::new(140, 44)).unwrap();
754 terminal.draw(|frame| draw(frame, wizard)).unwrap();
755 terminal.backend().text()
756 }
757
758 #[test]
762 fn narrow_window_wraps_provider_blurbs_instead_of_clipping() {
763 let (_dir, mut w) = wizard();
764 w.enter(Step::Providers);
765 let mut terminal = Terminal::new(TestBackendHarness::new(48, 44)).unwrap();
766 terminal.draw(|frame| draw(frame, &w)).unwrap();
767 let screen = terminal.backend().text();
768 assert!(
769 screen.contains("disabled"),
770 "the blurb tail must survive a 48-column window:\n{screen}"
771 );
772 }
773
774 #[test]
775 fn wrap_plain_wraps_at_word_boundaries_and_hard_breaks_long_words() {
776 assert_eq!(
777 wrap_plain("alpha beta gamma", 11),
778 vec!["alpha beta", "gamma"]
779 );
780 assert_eq!(wrap_plain("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
782 assert_eq!(
784 wrap_plain("\u{65e5}\u{672c}\u{8a9e}\u{3067}\u{3059}", 2),
785 vec!["\u{65e5}\u{672c}", "\u{8a9e}\u{3067}", "\u{3059}"]
786 );
787 assert!(wrap_plain("", 10).is_empty());
788 assert_eq!(wrap_plain("ab", 0), vec!["a", "b"]);
790 }
791
792 fn wizard() -> (tempfile::TempDir, Wizard) {
793 let dir = tempfile::tempdir().unwrap();
794 let wizard = crate::commands::setup::state::tests::test_wizard(dir.path());
795 (dir, wizard)
796 }
797
798 #[test]
799 fn every_step_draws_without_panicking_and_names_itself() {
800 let dir = tempfile::tempdir().unwrap();
803 let mut w = Wizard::new(
804 Config::default(),
805 &|_| None,
806 vec![(
807 "Claude Code".to_string(),
808 crate::commands::setup::import::Candidate {
809 config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
810 scope: "/repo".to_string(),
811 inline_secrets: vec!["API_TOKEN".to_string()],
812 },
813 )],
814 vec!["Zed: unreadable".to_string()],
815 dir.path(),
816 std::sync::Arc::new(|_| true),
817 );
818 w.providers[0].selected = true;
819
820 for step in Step::ALL {
821 w.enter(step);
822 let screen = rendered(&w);
823 assert!(
824 screen.contains(step.title()),
825 "{step:?} did not name itself:\n{screen}"
826 );
827 }
828 }
829
830 #[test]
831 fn a_tiny_terminal_still_draws() {
832 let (_dir, w) = wizard();
834 let mut terminal = Terminal::new(TestBackendHarness::new(20, 8)).unwrap();
835
836 assert!(terminal.draw(|frame| draw(frame, &w)).is_ok());
837 }
838
839 #[test]
840 fn the_welcome_screen_reports_what_is_already_there() {
841 let (_dir, mut w) = wizard();
842 assert!(rendered(&w).contains("Nothing is configured yet"));
843
844 w.providers[0].selected = true;
845 let screen = rendered(&w);
846 assert!(screen.contains("Already configured"));
847 assert!(screen.contains("Anthropic"));
848 }
849
850 #[test]
851 fn the_provider_list_marks_where_each_credential_came_from() {
852 let dir = tempfile::tempdir().unwrap();
853 let mut w = Wizard::new(
854 Config::default(),
855 &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
856 Vec::new(),
857 Vec::new(),
858 dir.path(),
859 std::sync::Arc::new(|_| true),
860 );
861 w.providers[1].selected = true;
862 w.providers[1].value = "sk-oai".to_string();
863 w.enter(Step::Providers);
864
865 let screen = rendered(&w);
866 assert!(
867 screen.contains("$ANTHROPIC_API_KEY"),
868 "the environment source must be visible:\n{screen}"
869 );
870 assert!(screen.contains("(set)"), "{screen}");
871 assert!(!screen.contains("sk-oai"), "a key leaked:\n{screen}");
872 }
873
874 #[test]
875 fn a_stored_key_is_redacted_until_ctrl_r() {
876 let (_dir, mut w) = wizard();
877 w.providers[0].selected = true;
878 w.providers[0].value = "sk-ant-secret-value-here".to_string();
879 w.enter(Step::ProviderDetail);
880
881 let hidden = rendered(&w);
882 assert!(hidden.contains("****here"), "{hidden}");
884 assert!(
885 !hidden.contains("sk-ant-s"),
886 "issuer prefix leaked:\n{hidden}"
887 );
888 assert!(
889 !hidden.contains("secret-value"),
890 "the key leaked:\n{hidden}"
891 );
892
893 w.reveal = true;
894 assert!(rendered(&w).contains("sk-ant-secret-value-here"));
895 }
896
897 #[test]
898 fn a_key_being_typed_is_masked_until_revealed() {
899 let (_dir, mut w) = wizard();
900 w.providers[0].selected = true;
901 w.enter(Step::ProviderDetail);
902 w.edit = Some(Edit {
903 target: EditTarget::Credential(0),
904 line: crate::tui::widgets::line_edit::LineEdit::new("sk-typing".to_string(), true),
905 });
906
907 let hidden = rendered(&w);
908 assert!(hidden.contains("•••"), "{hidden}");
909 assert!(!hidden.contains("sk-typing"), "the key leaked:\n{hidden}");
910
911 w.reveal = true;
912 assert!(rendered(&w).contains("sk-typing"));
913 }
914
915 #[test]
916 fn an_empty_credential_shows_its_placeholder_or_its_source() {
917 let (_dir, mut w) = wizard();
918 w.providers[0].selected = true;
919 w.enter(Step::ProviderDetail);
920 assert!(rendered(&w).contains("sk-ant-..."));
921
922 w.providers[0].from_env = Some("ANTHROPIC_API_KEY");
923 let screen = rendered(&w);
924 assert!(screen.contains("from the environment"));
925 assert!(
926 screen.contains("will not be written"),
927 "the user must know the key stays where they put it:\n{screen}"
928 );
929 }
930
931 #[test]
932 fn a_base_url_is_never_masked() {
933 let (_dir, mut w) = wizard();
935 let ollama = w
936 .providers
937 .iter()
938 .position(|r| r.provider.id == "ollama")
939 .expect("ollama is offered");
940 w.providers[ollama].selected = true;
941 w.providers[ollama].value = "http://box:11434".to_string();
942 w.enter(Step::ProviderDetail);
943
944 assert!(rendered(&w).contains("http://box:11434"));
945 }
946
947 #[test]
948 fn every_verification_state_is_drawn_distinctly() {
949 let (_dir, mut w) = wizard();
950 w.providers[0].selected = true;
951 w.providers[0].value = "sk-ant".to_string();
952 w.enter(Step::ProviderDetail);
953
954 assert!(rendered(&w).contains("not checked yet"));
955
956 w.providers[0].checking = true;
957 assert!(rendered(&w).contains("checking"));
958
959 w.providers[0].checking = false;
960 w.providers[0].outcome = crate::commands::setup::verify::Outcome::Reachable {
961 models: vec!["a".into(), "b".into()],
962 };
963 assert!(rendered(&w).contains("2 models"));
964
965 w.providers[0].outcome = crate::commands::setup::verify::Outcome::Failed {
966 message: "rejected - check the key".into(),
967 };
968 assert!(rendered(&w).contains("rejected"));
969 }
970
971 #[test]
972 fn the_claude_code_card_shows_its_effort_and_its_caveat() {
973 let (_dir, mut w) = wizard();
974 let index = w
975 .providers
976 .iter()
977 .position(|r| r.provider.id == "claude-code")
978 .expect("the transport is offered");
979 w.providers[index].selected = true;
980 w.enter(Step::ProviderDetail);
981
982 let screen = rendered(&w);
983 assert!(screen.contains("Reasoning effort"));
984 assert!(
985 screen.contains("email"),
986 "the privacy cost must be on screen"
987 );
988 assert!(
989 screen.contains("terms"),
990 "the terms-of-service risk must be on screen:\n{screen}"
991 );
992 }
993
994 #[test]
995 fn the_review_screen_warns_about_the_claude_code_terms() {
996 let (_dir, mut w) = wizard();
997 let index = w
998 .providers
999 .iter()
1000 .position(|r| r.provider.id == "claude-code")
1001 .expect("the transport is offered");
1002 w.enter(Step::Review);
1003 assert!(
1004 !rendered(&w).contains("Claude Code transport: Anthropic"),
1005 "the warning is only for a setup that enables it"
1006 );
1007
1008 w.providers[index].selected = true;
1009 let screen = rendered(&w);
1010 assert!(
1011 screen.contains("Claude Code transport: Anthropic"),
1012 "{screen}"
1013 );
1014 assert!(screen.contains("responsibility for compliance"), "{screen}");
1015 }
1016
1017 #[test]
1018 fn the_tos_confirmation_dialog_draws_over_the_review_screen() {
1019 let (_dir, mut w) = wizard();
1020 let index = w
1021 .providers
1022 .iter()
1023 .position(|r| r.provider.id == "claude-code")
1024 .expect("the transport is offered");
1025 w.providers[index].selected = true;
1026 w.enter(Step::Review);
1027 w.open_tos_confirm();
1028
1029 let screen = rendered(&w);
1030 assert!(
1031 screen.contains("terms of service"),
1032 "dialog title missing:\n{screen}"
1033 );
1034 assert!(
1035 screen.contains("[ Accept and save ]"),
1036 "the affirmative button is missing:\n{screen}"
1037 );
1038 assert!(
1039 screen.contains("[ Cancel ]"),
1040 "the safe button is missing:\n{screen}"
1041 );
1042 }
1043
1044 #[test]
1045 fn the_quit_and_no_provider_dialogs_draw_their_buttons() {
1046 let (_dir, mut w) = wizard();
1047 w.open_quit_confirm();
1048 let screen = rendered(&w);
1049 assert!(screen.contains("Quit setup?"), "{screen}");
1050 assert!(screen.contains("[ Stay ]"), "{screen}");
1051
1052 w.confirm = None;
1053 w.open_no_providers_confirm();
1054 let screen = rendered(&w);
1055 assert!(screen.contains("No providers selected"), "{screen}");
1056 assert!(screen.contains("[ Go back ]"), "{screen}");
1057 assert!(screen.contains("[ Continue anyway ]"), "{screen}");
1058 }
1059
1060 #[test]
1061 fn the_credential_screen_draws_nothing_when_no_provider_is_selected() {
1062 let (_dir, mut w) = wizard();
1063 w.enter(Step::ProviderDetail);
1064
1065 assert!(rendered(&w).contains("Credentials"));
1067 }
1068
1069 #[test]
1070 fn the_credential_screen_moves_the_focus_marker_onto_the_continue_button() {
1071 let (_dir, mut w) = wizard();
1072 w.providers[0].selected = true;
1073 w.enter(Step::ProviderDetail);
1074
1075 assert!(rendered(&w).contains("› API key:"));
1077
1078 w.cursor = w.row_count();
1080 let screen = rendered(&w);
1081 assert!(!screen.contains("› API key:"), "{screen}");
1082 assert!(screen.contains("› [ Continue: Defaults ]"), "{screen}");
1083 }
1084
1085 #[test]
1086 fn a_field_being_edited_shows_the_buffer_not_the_stored_value() {
1087 let (_dir, mut w) = wizard();
1088 w.enter(Step::Limits);
1089 w.edit = Some(Edit {
1090 target: EditTarget::Field(0),
1091 line: crate::tui::widgets::line_edit::LineEdit::new("42".to_string(), false),
1092 });
1093
1094 assert!(rendered(&w).contains("42"));
1095 }
1096
1097 #[test]
1098 fn each_field_kind_advertises_the_key_that_changes_it() {
1099 let (_dir, mut w) = wizard();
1100 w.enter(Step::Limits);
1101 let screen = rendered(&w);
1102 assert!(screen.contains("[enter]"), "numbers are typed");
1103 assert!(screen.contains("[enter/space]"), "booleans are toggled");
1104
1105 w.providers[0].selected = true;
1106 w.enter(Step::Defaults);
1107 assert!(rendered(&w).contains("enter/← →"), "choices are cycled");
1108 }
1109
1110 #[test]
1111 fn the_agent_list_shows_what_each_row_would_do() {
1112 let dir = tempfile::tempdir().unwrap();
1113 crate::bundled::install_bundled(&crate::bundled::BUNDLED_AGENTS[0], dir.path()).unwrap();
1114 let mut w = crate::commands::setup::state::tests::test_wizard(dir.path());
1115 w.enter(Step::Agents);
1116
1117 let screen = rendered(&w);
1118 assert!(screen.contains("up to date"));
1119 let not_installed = &crate::bundled::BUNDLED_AGENTS[1];
1122 assert!(screen.contains(&format!("install {}", not_installed.version)));
1123 }
1124
1125 #[test]
1126 fn the_mcp_list_flags_collisions_scopes_and_inline_secrets() {
1127 let dir = tempfile::tempdir().unwrap();
1128 let base = Config {
1129 mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![])],
1130 ..Config::default()
1131 };
1132 let mut candidate = crate::commands::setup::import::Candidate {
1133 config: leviath_mcp::MCPServerConfig::http("fs", "https://x.test/mcp"),
1134 scope: "/repo".to_string(),
1135 inline_secrets: vec!["Authorization".to_string()],
1136 };
1137 candidate.config.name = "fs".to_string();
1138 let mut w = Wizard::new(
1139 base,
1140 &|_| None,
1141 vec![("Cursor".to_string(), candidate)],
1142 vec!["Zed: couldn't parse this".to_string()],
1143 dir.path(),
1144 std::sync::Arc::new(|_| true),
1145 );
1146 w.enter(Step::Mcp);
1147
1148 let screen = rendered(&w);
1149 assert!(screen.contains("from Cursor"));
1150 assert!(screen.contains("/repo"));
1151 assert!(screen.contains("already configured"));
1152 assert!(screen.contains("fs-2"), "the free name is shown");
1153 assert!(screen.contains("literal secret"));
1154 assert!(screen.contains("Zed"), "the unreadable source is reported");
1155 assert!(screen.contains("https://x.test/mcp"));
1156 }
1157
1158 #[test]
1159 fn an_imported_stdio_server_shows_its_command() {
1160 let dir = tempfile::tempdir().unwrap();
1161 let mut w = Wizard::new(
1162 Config::default(),
1163 &|_| None,
1164 vec![(
1165 "Codex".to_string(),
1166 crate::commands::setup::import::Candidate {
1167 config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
1168 scope: String::new(),
1169 inline_secrets: Vec::new(),
1170 },
1171 )],
1172 Vec::new(),
1173 dir.path(),
1174 std::sync::Arc::new(|_| true),
1175 );
1176 w.enter(Step::Mcp);
1177
1178 assert!(rendered(&w).contains("npx"));
1179 }
1180
1181 #[test]
1182 fn the_review_screen_lists_changes_and_both_kinds_of_warning() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let mut w = Wizard::new(
1185 Config::default(),
1186 &|_| None,
1187 vec![(
1188 "Cursor".to_string(),
1189 crate::commands::setup::import::Candidate {
1190 config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
1191 scope: String::new(),
1192 inline_secrets: vec!["API_TOKEN".to_string()],
1193 },
1194 )],
1195 Vec::new(),
1196 dir.path(),
1197 std::sync::Arc::new(|_| true),
1198 );
1199 w.providers[0].selected = true;
1200 w.providers[0].value = "sk-ant-x".to_string();
1201 w.providers[0].outcome = crate::commands::setup::verify::Outcome::Failed {
1202 message: "rejected - check the key".into(),
1203 };
1204 w.enter(Step::Review);
1205
1206 let screen = rendered(&w);
1207 assert!(screen.contains("credential set"));
1208 assert!(screen.contains("written out in full"), "secret warning");
1209 assert!(screen.contains("API_TOKEN"));
1210 assert!(screen.contains("Did not verify"));
1211 assert!(
1212 screen.contains("saving anyway is fine"),
1213 "a failed check must not read as a blocker"
1214 );
1215 }
1216
1217 #[test]
1218 fn the_review_screen_says_when_nothing_would_change() {
1219 let (_dir, mut w) = wizard();
1220 for row in w.agents.iter_mut() {
1221 row.selected = false;
1222 }
1223 w.enter(Step::Review);
1224
1225 assert!(rendered(&w).contains("Nothing would change"));
1226 }
1227
1228 #[test]
1229 fn the_footer_shows_a_message_and_the_right_hints_per_screen() {
1230 let (_dir, mut w) = wizard();
1231 w.message = Some("Credentials shown.".to_string());
1232 assert!(rendered(&w).contains("Credentials shown."));
1233
1234 w.message = None;
1235 for (step, expected) in [
1236 (Step::Welcome, "enter begin"),
1237 (Step::Providers, "space/enter select"),
1238 (Step::ProviderDetail, "enter edit"),
1239 (Step::Defaults, "enter change"),
1240 (Step::Limits, "enter change"),
1241 (Step::Agents, "space/enter select"),
1242 (Step::Mcp, "space/enter select"),
1243 (Step::Review, "enter apply"),
1244 ] {
1245 w.enter(step);
1246 let screen = rendered(&w);
1247 assert!(
1248 screen.contains(expected),
1249 "{step:?} footer missing {expected:?}:\n{screen}"
1250 );
1251 }
1252
1253 w.edit = Some(Edit {
1254 target: EditTarget::Field(0),
1255 line: crate::tui::widgets::line_edit::LineEdit::new(String::new(), false),
1256 });
1257 assert!(rendered(&w).contains("esc cancel"));
1258
1259 w.edit = None;
1261 w.open_quit_confirm();
1262 assert!(rendered(&w).contains("enter confirm"));
1263 }
1264
1265 #[test]
1266 fn the_help_overlay_lists_the_bindings() {
1267 let (_dir, mut w) = wizard();
1268 w.show_help = true;
1269
1270 let screen = rendered(&w);
1271 assert!(screen.contains("Help"));
1272 assert!(screen.contains("ctrl-s"));
1273 assert!(screen.contains("ctrl-r"));
1274 }
1275
1276 #[test]
1277 fn the_breadcrumb_marks_done_current_and_upcoming_steps() {
1278 let (_dir, mut w) = wizard();
1279 w.enter(Step::Agents);
1280
1281 let screen = rendered(&w);
1282 for step in Step::ALL {
1283 assert!(
1284 screen.contains(step.title()),
1285 "{step:?} missing from header"
1286 );
1287 }
1288 }
1289
1290 #[test]
1291 fn a_choice_field_with_an_out_of_range_index_draws_a_placeholder() {
1292 let (_dir, mut w) = wizard();
1293 w.enter(Step::Defaults);
1294 w.defaults[0].value = FieldValue::Choice {
1295 options: vec!["a".to_string()],
1296 index: 9,
1297 };
1298
1299 assert!(rendered(&w).contains("(none)"));
1300 }
1301}