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            // Dim for "nothing to do", and for a locally edited install too:
501            // it is offered, not urged, because reinstalling destroys the edit.
502            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
671/// The footer's key hints for the wizard's current mode.
672fn 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
735/// Highlight style for the row under the cursor.
736fn 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    /// Render one frame and return every non-blank line of the buffer, so a
753    /// test can assert on what a user would actually read.
754    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    /// A provider blurb on a narrow window must wrap, not clip: the tail of
761    /// the sentence (here the transport's "cannot be disabled" caveat) has to
762    /// reach the screen.
763    #[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        // One word wider than the pane hard-breaks by characters.
783        assert_eq!(wrap_plain("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
784        // Multibyte characters break on char boundaries, never mid-character.
785        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        // A degenerate zero width still terminates and yields one char per line.
791        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        // The cheapest guard against a layout arm that only blows up on the one
803        // screen nobody opened during testing.
804        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        // Layout constraints that assume room can panic on a small window.
835        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        // Last four characters, not the first eight - see `catalog::redact`.
885        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        // It is not a secret, and hiding it would just be annoying.
936        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        // Just the chrome and the Continue button, no panic.
1068        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        // Cursor on the credential row: the row carries the marker.
1078        assert!(rendered(&w).contains("› API key:"));
1079
1080        // Cursor on the Continue button: the row loses it, the button gains it.
1081        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        // Read the expected version off the bundled agent rather than spelling
1122        // it out, so bumping a blueprint does not break this test.
1123        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        // A dialog swaps the footer for its own answer hints.
1262        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}