Skip to main content

leviath_cli/commands/setup/
render.rs

1//! Drawing the setup wizard.
2//!
3//! One `draw` per frame, laid out as a fixed header (step breadcrumb), a body
4//! that varies per step, and a footer (message line plus key hints), with an
5//! optional help overlay on top. Every function takes `&Wizard` and produces
6//! widgets - no state changes here, so a render can never be the reason
7//! something moved.
8
9use 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
23/// Draw one frame.
24pub 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
45/// The help overlay's content, matching the bindings in `input.rs`.
46fn 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
80/// The step's Continue/action button, rendered as the last cursor row.
81fn 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
99/// The step breadcrumb.
100fn 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
130/// The step's own content.
131fn 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
206/// Greedy word-wrap for plain text rendered inside `List` items, which
207/// (unlike `Paragraph`) cannot wrap. Words longer than `width` break at a
208/// character boundary rather than overflowing.
209fn 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                // A single word wider than the pane: hard-break it after
230                // `width` characters (a char boundary by construction).
231                // This branch is only reachable when the word has more than
232                // `width` chars (a shorter word takes the fits-branch above),
233                // so the boundary at char `width` always exists.
234                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                // The cut is strictly inside the word, so the tail is never
242                // empty; the loop re-tests it against the width.
243                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            // Word-wrap the blurb to the pane: `List` does not wrap, so a
283            // long description on a narrow window would otherwise clip
284            // mid-sentence with nothing to say more text exists.
285            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        // Forced onto an empty credential screen (tests do): only the button.
302        frame.render_widget(Paragraph::new(vec![continue_line(wizard)]), area);
303        return;
304    };
305    // `detail_row` yields an index into `providers`, so this is a read rather
306    // than a lookup that could miss.
307    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    // The credential (or effort) row is the screen's one cursor row; the
330    // marker shows whether it or the Continue button holds focus.
331    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            // The transport is opt-in, so the terms risk has to be on the
378            // screen where it is opted into - not only in the README.
379            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
401/// What to print in place of a credential when it is not being edited.
402fn 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
417/// The verification result line for one provider.
418fn 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
669/// The footer's key hints for the wizard's current mode.
670fn 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
733/// Highlight style for the row under the cursor.
734fn 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    /// Render one frame and return every non-blank line of the buffer, so a
751    /// test can assert on what a user would actually read.
752    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    /// A provider blurb on a narrow window must wrap, not clip: the tail of
759    /// the sentence (here the transport's "cannot be disabled" caveat) has to
760    /// reach the screen.
761    #[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        // One word wider than the pane hard-breaks by characters.
781        assert_eq!(wrap_plain("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
782        // Multibyte characters break on char boundaries, never mid-character.
783        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        // A degenerate zero width still terminates and yields one char per line.
789        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        // The cheapest guard against a layout arm that only blows up on the one
801        // screen nobody opened during testing.
802        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        // Layout constraints that assume room can panic on a small window.
833        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        // Last four characters, not the first eight - see `catalog::redact`.
883        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        // It is not a secret, and hiding it would just be annoying.
934        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        // Just the chrome and the Continue button, no panic.
1066        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        // Cursor on the credential row: the row carries the marker.
1076        assert!(rendered(&w).contains("› API key:"));
1077
1078        // Cursor on the Continue button: the row loses it, the button gains it.
1079        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        // Read the expected version off the bundled agent rather than spelling
1120        // it out, so bumping a blueprint does not break this test.
1121        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        // A dialog swaps the footer for its own answer hints.
1260        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}