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