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//!
9//! Every step builds a `Screen`: flat lines, plus the line each selectable
10//! row starts on. That shape is what makes the wizard survive a small window.
11//! The screens used to be `List`s of pre-sized items and assumed the terminal
12//! was tall enough, so the tuning screen's thirteen fields simply stopped at
13//! whatever row ran out of pane, with nothing on screen to say more existed.
14//! Wrapping happens here too, in `wrap_line`, for the same reason: the
15//! number of rows a screen occupies is only knowable once its text is wrapped,
16//! and without that number there is nothing to scroll against.
17
18use ratatui::{
19    Frame,
20    layout::{Constraint, Direction, Layout, Margin, Rect},
21    style::{Modifier, Style},
22    text::{Line, Span},
23    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
24};
25
26use super::catalog::{self, Credential};
27use super::state::{FieldValue, Picker, Step, Wizard};
28use crate::tui::theme::*;
29use crate::tui::widgets::footer::{Hint, draw_hint_bar, hint};
30use crate::tui::widgets::help::{HelpSection, draw_help};
31use crate::tui::widgets::popup::{centered, popup_frame};
32
33/// The smallest window the wizard will try to draw in. Below this there is no
34/// honest layout left, and half a bordered pane reads as a broken program
35/// rather than a small one.
36const MIN_WIDTH: u16 = 24;
37const MIN_HEIGHT: u16 = 6;
38
39/// Draw one frame.
40pub fn draw(frame: &mut Frame, wizard: &Wizard) {
41    let area = frame.area();
42    if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
43        frame.render_widget(
44            Paragraph::new(vec![
45                Line::from(Span::styled(
46                    "Window too small",
47                    Style::default().fg(C_WARN).add_modifier(Modifier::BOLD),
48                )),
49                Line::from(Span::styled(
50                    format!("Need {MIN_WIDTH}x{MIN_HEIGHT}"),
51                    Style::default().fg(C_MUTED),
52                )),
53            ]),
54            area,
55        );
56        return;
57    }
58
59    let chunks = body_layout(area);
60    if chunks[0].height > 0 {
61        draw_header(frame, chunks[0], wizard);
62    }
63    draw_body(frame, chunks[1], wizard);
64    draw_footer(frame, chunks[2], wizard);
65
66    if let Some(pending) = &wizard.confirm {
67        pending.dialog.draw(frame, frame.area());
68    } else if let Some(picker) = &wizard.picker {
69        draw_picker(frame, frame.area(), picker);
70    } else if wizard.show_help {
71        draw_help(frame, frame.area(), &help_sections(), &wizard.help_scroll);
72    }
73}
74
75/// The chooser's split: prose, search box, then the list with the rest.
76fn picker_layout(inner: Rect, picker: &Picker) -> std::rc::Rc<[Rect]> {
77    let explain = (picker.explain.len() as u16 + 2).min(inner.height.saturating_sub(4));
78    Layout::default()
79        .direction(Direction::Vertical)
80        .constraints([
81            Constraint::Length(explain),
82            Constraint::Length(2),
83            Constraint::Min(1),
84        ])
85        .split(inner)
86}
87
88/// Which option a click in the chooser landed on, as an index into the
89/// filtered list. Shares `picker_layout` with the drawing, so a click cannot
90/// resolve against rows that were not on screen.
91pub fn picker_row_at(area: Rect, picker: &Picker, row: u16) -> Option<usize> {
92    let popup = centered(80, 88, area);
93    // What `popup_frame` leaves after its border.
94    let inner = Block::default().borders(Borders::ALL).inner(popup);
95    if inner.height < 4 {
96        return None;
97    }
98    let list = picker_layout(inner, picker)[2];
99    if row < list.y || row >= list.y + list.height {
100        return None;
101    }
102    let height = list.height as usize;
103    let offset = picker.cursor.saturating_sub(height.saturating_sub(1));
104    let position = offset + (row - list.y) as usize;
105    (position < picker.matches().len()).then_some(position)
106}
107
108/// Draw the chooser over everything else.
109///
110/// It takes most of the window rather than a small popup: the whole complaint
111/// it answers is that a long list read through one line is unreadable, and the
112/// prose above it is the other half of the answer.
113fn draw_picker(frame: &mut Frame, area: Rect, picker: &Picker) {
114    let popup = centered(80, 88, area);
115    let inner = popup_frame(frame, popup, picker.title, C_BORDER_FOCUS);
116    let chunks = picker_layout(inner, picker);
117
118    let mut lines: Vec<Line<'static>> = picker
119        .explain
120        .iter()
121        .map(|text| Line::from(Span::styled(*text, Style::default().fg(C_MUTED))))
122        .collect();
123    lines.push(Line::from(""));
124    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), chunks[0]);
125
126    let mut search = vec![Span::styled("Search  ", Style::default().fg(C_DIM))];
127    search.extend(picker.query.display_spans(true).spans);
128    frame.render_widget(
129        Paragraph::new(vec![Line::from(search), Line::from("")]),
130        chunks[1],
131    );
132
133    let matches = picker.matches();
134    if matches.is_empty() {
135        frame.render_widget(
136            Paragraph::new(Line::from(Span::styled(
137                "Nothing matches that.",
138                Style::default().fg(C_WARN),
139            ))),
140            chunks[2],
141        );
142        return;
143    }
144
145    let height = chunks[2].height as usize;
146    // Keep the cursor in view without a stored offset: the list is rebuilt
147    // every frame anyway, so the window into it is arithmetic, not state.
148    let offset = picker.cursor.saturating_sub(height.saturating_sub(1));
149    let rows: Vec<Line<'static>> = matches
150        .iter()
151        .enumerate()
152        .skip(offset)
153        .take(height)
154        .map(|(position, option)| {
155            let option = &picker.options[*option];
156            let selected = position == picker.cursor;
157            Line::from(vec![
158                Span::styled(
159                    if selected { "› " } else { "  " },
160                    Style::default().fg(C_ACCENT),
161                ),
162                Span::styled(
163                    format!("{:<38}", option.value),
164                    if selected {
165                        Style::default().fg(C_ACTIVE).add_modifier(Modifier::BOLD)
166                    } else {
167                        Style::default().fg(C_WHITE)
168                    },
169                ),
170                Span::styled(option.detail.clone(), Style::default().fg(C_DIM)),
171            ])
172        })
173        .collect();
174    frame.render_widget(Paragraph::new(rows), chunks[2]);
175}
176
177/// The help overlay's content, matching the bindings in `input.rs`.
178fn help_sections() -> [HelpSection; 3] {
179    [
180        HelpSection {
181            title: "Navigate",
182            entries: vec![
183                ("↑ ↓ / k j", "move"),
184                ("pgup / pgdn", "scroll a page"),
185                ("home / end", "first row / the button"),
186                ("← → / h l", "change a choice"),
187                ("space", "select / toggle"),
188                ("enter", "act on the focused row; Continue moves on"),
189                ("enter", "on a default, opens a searchable list"),
190                ("tab", "next screen"),
191                ("shift-tab / esc", "previous screen"),
192            ],
193        },
194        HelpSection {
195            title: "Providers",
196            entries: vec![
197                ("v", "re-check a credential"),
198                ("o", "open a signup page"),
199                ("ctrl-r", "show or hide credentials"),
200            ],
201        },
202        HelpSection {
203            title: "Finish",
204            entries: vec![
205                ("ctrl-s", "write and finish, from anywhere"),
206                (
207                    "q / ctrl-c",
208                    "quit without writing (asks if you changed things)",
209                ),
210            ],
211        },
212    ]
213}
214
215/// The step's Continue/action button, rendered as the last cursor row.
216fn continue_line(wizard: &Wizard) -> Line<'static> {
217    button_line(&wizard.continue_label(), wizard.on_continue())
218}
219
220/// The step breadcrumb.
221fn draw_header(frame: &mut Frame, area: Rect, wizard: &Wizard) {
222    let current = wizard.step.index();
223    let mut spans = vec![Span::styled(
224        "Leviath setup  ",
225        Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD),
226    )];
227    for (index, step) in Step::ALL.iter().enumerate() {
228        if index > 0 {
229            spans.push(Span::styled(" › ", Style::default().fg(C_DIM)));
230        }
231        let style = if index == current {
232            Style::default().fg(C_ACCENT).add_modifier(Modifier::BOLD)
233        } else if index < current {
234            Style::default().fg(C_SUCCESS)
235        } else {
236            Style::default().fg(C_DIM)
237        };
238        spans.push(Span::styled(step.title(), style));
239    }
240
241    frame.render_widget(
242        Paragraph::new(Line::from(spans)).block(
243            Block::default()
244                .borders(Borders::ALL)
245                .border_style(Style::default().fg(C_BORDER)),
246        ),
247        area,
248    );
249}
250
251/// One step's content: flat lines, plus where each selectable row begins.
252///
253/// The row index is what lets scrolling follow the selection. A `List` tracks
254/// that itself, but its items cannot wrap, and a wizard row is a label and a
255/// help line that both have to fold on a narrow window.
256#[derive(Default)]
257struct Screen {
258    lines: Vec<Line<'static>>,
259    /// First line of each selectable row, in cursor order. The last entry is
260    /// always the Continue button, matching [`Wizard::nav_rows`].
261    rows: Vec<usize>,
262}
263
264impl Screen {
265    /// Mark the next line as the start of the next selectable row.
266    fn row(&mut self) {
267        self.rows.push(self.lines.len());
268    }
269
270    fn push(&mut self, line: Line<'static>) {
271        self.lines.push(line);
272    }
273
274    fn blank(&mut self) {
275        self.lines.push(Line::from(""));
276    }
277
278    /// Close the screen with its Continue button, which every step has.
279    fn finish(mut self, wizard: &Wizard) -> Self {
280        self.blank();
281        self.row();
282        self.push(continue_line(wizard));
283        self
284    }
285
286    /// Re-flow to `width`, keeping the row markers pointing at the same rows.
287    fn wrapped(self, width: usize) -> Self {
288        let mut out = Screen::default();
289        let mut rows = self.rows.iter().peekable();
290        for (index, line) in self.lines.iter().enumerate() {
291            while rows.peek().is_some_and(|start| **start == index) {
292                rows.next();
293                out.row();
294            }
295            out.lines.extend(wrap_line(line, width));
296        }
297        out
298    }
299}
300
301/// The step's own content.
302fn draw_body(frame: &mut Frame, area: Rect, wizard: &Wizard) {
303    let block = Block::default()
304        .borders(Borders::ALL)
305        .border_style(Style::default().fg(C_BORDER_FOCUS))
306        .title(format!(" {} ", wizard.step.title()));
307    let inner = block.inner(area);
308    frame.render_widget(block, area);
309
310    draw_screen(
311        frame,
312        inner,
313        area,
314        &build_screen(wizard).wrapped(inner.width as usize),
315        wizard,
316    );
317}
318
319/// Which selectable row, if any, sits under a point in the window.
320///
321/// This rebuilds the layout the last frame used rather than remembering it.
322/// A stored layout would make drawing a state change, and the wizard's one
323/// rule is that a render never moves anything; rebuilding costs a screenful of
324/// lines on a click, which is not a cost worth trading that rule for.
325pub fn row_at(area: Rect, wizard: &Wizard, column: u16, row: u16) -> Option<usize> {
326    if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
327        return None;
328    }
329    let chunks = body_layout(area);
330    let block = Block::default().borders(Borders::ALL);
331    let inner = block.inner(chunks[1]);
332    if column < inner.x
333        || column >= inner.x + inner.width
334        || row < inner.y
335        || row >= inner.y + inner.height
336    {
337        return None;
338    }
339
340    let screen = build_screen(wizard).wrapped(inner.width as usize);
341    let offset = first_visible(&screen, wizard, inner.height as usize);
342    let line = offset + (row - inner.y) as usize;
343    // The row that owns this line is the last one starting at or before it,
344    // and only if the line is still inside the screen's content.
345    if line >= screen.lines.len() {
346        return None;
347    }
348    screen
349        .rows
350        .iter()
351        .rposition(|start| *start <= line)
352        .filter(|_| screen.rows.first().is_some_and(|first| *first <= line))
353}
354
355/// The header/body/footer split, shared by drawing and hit-testing so a click
356/// can never land on a layout the frame did not use.
357fn body_layout(area: Rect) -> std::rc::Rc<[Rect]> {
358    // The breadcrumb is the first thing to go on a short window. It says where
359    // you are, which the body's own title also says, so spending three of
360    // twelve rows on it costs more than it tells you.
361    let header = if area.height >= 14 { 3 } else { 0 };
362    Layout::default()
363        .direction(Direction::Vertical)
364        .constraints([
365            Constraint::Length(header),
366            Constraint::Min(3),
367            Constraint::Length(3),
368        ])
369        .split(area)
370}
371
372/// The current step's content, before wrapping.
373fn build_screen(wizard: &Wizard) -> Screen {
374    match wizard.step {
375        Step::Welcome => build_welcome(wizard),
376        Step::Providers => build_providers(wizard),
377        Step::ProviderDetail => build_provider_detail(wizard),
378        Step::Defaults | Step::Limits => build_fields(wizard),
379        Step::Agents => build_agents(wizard),
380        Step::Mcp => build_mcp(wizard),
381        Step::Review => build_review(wizard),
382    }
383}
384
385/// Total width of a styled line, counted the way [`wrap_line`] counts.
386fn line_width(line: &Line<'_>) -> usize {
387    line.spans.iter().map(|s| s.content.chars().count()).sum()
388}
389
390/// Split into alternating runs of whitespace and non-whitespace, so wrapping
391/// can keep column padding that fits and drop it at a break.
392fn runs(text: &str) -> Vec<&str> {
393    let mut out = Vec::new();
394    let mut rest = text;
395    while !rest.is_empty() {
396        let blank = rest.starts_with(char::is_whitespace);
397        let end = rest
398            .char_indices()
399            .find(|(_, c)| c.is_whitespace() != blank)
400            .map_or(rest.len(), |(i, _)| i);
401        let (head, tail) = rest.split_at(end);
402        out.push(head);
403        rest = tail;
404    }
405    out
406}
407
408/// Word-wrap a styled line to `width`, keeping every span's style and
409/// indenting continuations to the line's own leading spaces.
410///
411/// `Paragraph`'s `Wrap` would fold the text, but only inside the widget: the
412/// caller never learns how many rows came out, and the wizard needs that
413/// number to scroll. Wrapping up front means the row count and the scroll
414/// offset are the same units.
415fn wrap_line(line: &Line<'static>, width: usize) -> Vec<Line<'static>> {
416    let width = width.max(1);
417    if line_width(line) <= width {
418        return vec![line.clone()];
419    }
420    // A hanging indent keeps a wrapped help line reading as one item, but only
421    // when it leaves most of the width for text.
422    let indent_len = line
423        .spans
424        .first()
425        .map_or(0, |s| s.content.chars().take_while(|c| *c == ' ').count());
426    let indent_len = if indent_len * 2 >= width {
427        0
428    } else {
429        indent_len
430    };
431
432    /// Close a row, dropping the whitespace it would otherwise end in: the
433    /// break stands in for the space it happened at.
434    ///
435    /// Indexing rather than `last_mut`, because a row is only ever closed with
436    /// something on it - `has_word` is what decides to close one.
437    fn close_row(spans: &mut Vec<Span<'static>>) -> Line<'static> {
438        while spans.len() > 1 && spans[spans.len() - 1].content.trim().is_empty() {
439            spans.pop();
440        }
441        let last = spans.len() - 1;
442        spans[last].content = spans[last].content.trim_end().to_string().into();
443        Line::from(std::mem::take(spans))
444    }
445
446    let mut out: Vec<Line<'static>> = Vec::new();
447    let mut current: Vec<Span<'static>> = Vec::new();
448    let mut used = 0usize;
449    let mut has_word = false;
450
451    for span in &line.spans {
452        for run in runs(&span.content) {
453            let blank = run.starts_with(char::is_whitespace);
454            // Whitespace that a break already stood in for.
455            if blank && !has_word && !out.is_empty() {
456                continue;
457            }
458            let mut piece = run;
459            loop {
460                let len = piece.chars().count();
461                if used + len <= width {
462                    current.push(Span::styled(piece.to_string(), span.style));
463                    used += len;
464                    has_word |= !blank;
465                    break;
466                }
467                if has_word {
468                    out.push(close_row(&mut current));
469                    used = indent_len;
470                    has_word = false;
471                    if indent_len > 0 {
472                        current.push(Span::raw(" ".repeat(indent_len)));
473                    }
474                    // The break stands in for the space that did not fit.
475                    if blank {
476                        break;
477                    }
478                    continue;
479                }
480                // A single word wider than the pane, on a line with nothing
481                // else on it: hard-break it at a char boundary. `used` is the
482                // indent here, which is strictly under `width`, so there is
483                // always at least one character of room.
484                let cut = piece
485                    .char_indices()
486                    .nth(width - used)
487                    .map(|(i, _)| i)
488                    .expect("infallible: the run is longer than the room left");
489                let (head, tail) = piece.split_at(cut);
490                current.push(Span::styled(head.to_string(), span.style));
491                out.push(close_row(&mut current));
492                used = indent_len;
493                if indent_len > 0 {
494                    current.push(Span::raw(" ".repeat(indent_len)));
495                }
496                piece = tail;
497            }
498        }
499    }
500    // Empty when the text ended exactly at a break.
501    if !current.is_empty() {
502        out.push(close_row(&mut current));
503    }
504    out
505}
506
507/// The first line to show, given where the user last scrolled and where the
508/// cursor is.
509///
510/// The cursor wins. `wizard.scroll` is what the wheel and the page keys move,
511/// but a selection the user cannot see is worse than a lost scroll position,
512/// so an off-screen cursor pulls the viewport back to it.
513fn first_visible(screen: &Screen, wizard: &Wizard, height: usize) -> usize {
514    let total = screen.lines.len();
515    let max = total.saturating_sub(height);
516    let mut offset = wizard.scroll.min(max);
517    let Some(&start) = screen.rows.get(wizard.cursor) else {
518        return offset;
519    };
520    // The row runs to the start of the next one, so a two-line field scrolls
521    // into view whole rather than showing its label with the help cut off.
522    let end = screen
523        .rows
524        .get(wizard.cursor + 1)
525        .copied()
526        .unwrap_or(total)
527        .max(start + 1);
528    if start < offset {
529        offset = start;
530    } else if end > offset + height {
531        offset = end.saturating_sub(height).min(start);
532    }
533    offset
534}
535
536/// Render a built screen into `inner`, with a scrollbar on `outer`'s border
537/// when there is more than fits.
538fn draw_screen(frame: &mut Frame, inner: Rect, outer: Rect, screen: &Screen, wizard: &Wizard) {
539    // At least one row: the floor in `draw` leaves the body three rows and its
540    // border takes two.
541    let height = inner.height as usize;
542    let offset = first_visible(screen, wizard, height);
543    frame.render_widget(
544        Paragraph::new(screen.lines.clone()).scroll((offset.min(u16::MAX as usize) as u16, 0)),
545        inner,
546    );
547
548    let total = screen.lines.len();
549    if total > height {
550        let mut state = ScrollbarState::new(total - height).position(offset);
551        frame.render_stateful_widget(
552            Scrollbar::new(ScrollbarOrientation::VerticalRight)
553                .begin_symbol(Some("↑"))
554                .end_symbol(Some("↓")),
555            outer.inner(Margin {
556                vertical: 1,
557                horizontal: 0,
558            }),
559            &mut state,
560        );
561    }
562}
563
564fn build_welcome(wizard: &Wizard) -> Screen {
565    let configured: Vec<&str> = wizard
566        .providers
567        .iter()
568        .filter(|r| r.selected)
569        .map(|r| r.provider.display)
570        .collect();
571    let pending = wizard.agents.iter().filter(|r| r.selected).count();
572
573    let mut lines = vec![
574        Line::from(Span::styled(
575            "This sets up providers, defaults, the bundled agents, and any MCP",
576            Style::default().fg(C_WHITE),
577        )),
578        Line::from(Span::styled(
579            "servers you already have configured in other tools.",
580            Style::default().fg(C_WHITE),
581        )),
582        Line::from(""),
583    ];
584
585    if configured.is_empty() {
586        lines.push(Line::from(Span::styled(
587            "Nothing is configured yet.",
588            Style::default().fg(C_MUTED),
589        )));
590    } else {
591        lines.push(Line::from(vec![
592            Span::styled("Already configured: ", Style::default().fg(C_MUTED)),
593            Span::styled(configured.join(", "), Style::default().fg(C_SUCCESS)),
594        ]));
595    }
596    lines.push(Line::from(vec![
597        Span::styled("Blueprints to install: ", Style::default().fg(C_MUTED)),
598        Span::styled(pending.to_string(), Style::default().fg(C_WHITE)),
599    ]));
600    if !wizard.mcp.is_empty() {
601        lines.push(Line::from(vec![
602            Span::styled(
603                "MCP servers found elsewhere: ",
604                Style::default().fg(C_MUTED),
605            ),
606            Span::styled(wizard.mcp.len().to_string(), Style::default().fg(C_WHITE)),
607        ]));
608    }
609    lines.push(Line::from(""));
610    lines.push(Line::from(Span::styled(
611        "Nothing is written until the last screen.",
612        Style::default().fg(C_DIM),
613    )));
614
615    Screen {
616        lines,
617        rows: Vec::new(),
618    }
619    .finish(wizard)
620}
621
622fn build_providers(wizard: &Wizard) -> Screen {
623    let mut screen = Screen::default();
624    for (index, row) in wizard.providers.iter().enumerate() {
625        let mark = if row.selected {
626            GLYPH_COMPLETE
627        } else {
628            GLYPH_PENDING
629        };
630        let mut spans = vec![
631            Span::styled(
632                format!("{mark} "),
633                Style::default().fg(if row.selected { C_SUCCESS } else { C_DIM }),
634            ),
635            Span::styled(row.provider.display, name_style(index == wizard.cursor)),
636        ];
637        if let Some(var) = row.from_env {
638            spans.push(Span::styled(
639                format!("  (${var})"),
640                Style::default().fg(C_WARN),
641            ));
642        } else if !row.value.is_empty() {
643            spans.push(Span::styled("  (set)", Style::default().fg(C_MUTED)));
644        }
645        screen.row();
646        screen.push(Line::from(spans));
647        screen.push(Line::from(Span::styled(
648            format!("    {}", row.provider.blurb),
649            Style::default().fg(C_DIM),
650        )));
651    }
652    screen.finish(wizard)
653}
654
655fn build_provider_detail(wizard: &Wizard) -> Screen {
656    let Some(index) = wizard.detail_row() else {
657        // Forced onto an empty credential screen (tests do): only the button.
658        return Screen::default().finish(wizard);
659    };
660    // `detail_row` yields an index into `providers`, so this is a read rather
661    // than a lookup that could miss.
662    let row = &wizard.providers[index];
663    let position = wizard.detail + 1;
664    let total = wizard.selected_providers().len();
665
666    let mut lines = vec![
667        Line::from(vec![
668            Span::styled(
669                row.provider.display,
670                Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD),
671            ),
672            Span::styled(
673                format!("   {position} of {total}"),
674                Style::default().fg(C_DIM),
675            ),
676        ]),
677        Line::from(Span::styled(
678            row.provider.blurb,
679            Style::default().fg(C_MUTED),
680        )),
681        Line::from(""),
682    ];
683
684    // The credential (or effort) row is the screen's one cursor row; the
685    // marker shows whether it or the Continue button holds focus.
686    let row_marker = if wizard.on_continue() { "  " } else { "› " };
687    let credential_row = lines.len();
688    match row.provider.credential {
689        Credential::ApiKey | Credential::BaseUrl => {
690            let label = if row.provider.credential == Credential::ApiKey {
691                "API key"
692            } else {
693                "Base URL"
694            };
695            let mut spans = vec![
696                Span::styled(row_marker, Style::default().fg(C_ACCENT)),
697                Span::styled(format!("{label}: "), Style::default().fg(C_MUTED)),
698            ];
699            match &wizard.edit {
700                Some(edit) if edit.target == super::state::EditTarget::Credential(index) => {
701                    spans.extend(edit.line.display_spans(wizard.reveal).spans);
702                }
703                _ => spans.push(Span::styled(
704                    credential_display(wizard, index),
705                    Style::default().fg(C_WHITE),
706                )),
707            }
708            lines.push(Line::from(spans));
709            if let Some(var) = row.from_env {
710                lines.push(Line::from(Span::styled(
711                    format!("Supplied by ${var} - it will not be written to the config."),
712                    Style::default().fg(C_WARN),
713                )));
714            }
715            lines.push(Line::from(Span::styled(
716                "Enter or click to edit.  Ctrl-R shows what you typed.",
717                Style::default().fg(C_DIM),
718            )));
719        }
720        Credential::None => {
721            lines.push(Line::from(vec![
722                Span::styled(row_marker, Style::default().fg(C_ACCENT)),
723                Span::styled("Reasoning effort: ", Style::default().fg(C_MUTED)),
724                Span::styled(
725                    super::state::effort_options()[row.effort],
726                    Style::default().fg(C_ACCENT),
727                ),
728            ]));
729            lines.push(Line::from(Span::styled(
730                "← / → to change.  Sign in with `claude` if you have not already.",
731                Style::default().fg(C_DIM),
732            )));
733            // The transport is opt-in, so the terms risk has to be on the
734            // screen where it is opted into - not only in the README.
735            for warning in [
736                "⚠️  Anthropic's terms prohibit third-party use of subscription auth",
737                "    without prior approval. By enabling this transport you accept",
738                "    responsibility for compliance with their terms.",
739                "    For unambiguous compliance, use a direct Anthropic API key.",
740            ] {
741                lines.push(Line::from(Span::styled(
742                    warning,
743                    Style::default().fg(C_WARN),
744                )));
745            }
746        }
747    }
748
749    lines.push(Line::from(""));
750    lines.push(status_line(wizard, index));
751    lines.push(Line::from(""));
752
753    let mut screen = Screen {
754        lines,
755        rows: vec![credential_row],
756    };
757    for (offset, action) in wizard.detail_actions().iter().enumerate() {
758        // Row 0 is the credential itself, so the actions start after it.
759        let focused = wizard.cursor == offset + 1;
760        screen.row();
761        screen.push(button_line(&action.label(row.provider.display), focused));
762    }
763    screen.finish(wizard)
764}
765
766/// A clickable action, drawn the same way the Continue button is so that what
767/// can be pressed looks like one thing.
768fn button_line(label: &str, focused: bool) -> Line<'static> {
769    let style = if focused {
770        Style::default()
771            .fg(C_ACCENT)
772            .add_modifier(Modifier::BOLD | Modifier::REVERSED)
773    } else {
774        Style::default().fg(C_MUTED)
775    };
776    Line::from(vec![
777        Span::styled(
778            if focused { "› " } else { "  " },
779            Style::default().fg(C_ACCENT),
780        ),
781        Span::styled(format!("[ {label} ]"), style),
782    ])
783}
784
785/// What to print in place of a credential when it is not being edited.
786fn credential_display(wizard: &Wizard, index: usize) -> String {
787    let row = &wizard.providers[index];
788    if row.value.is_empty() {
789        return match row.from_env {
790            Some(_) => "(from the environment)".to_string(),
791            None => format!("({})", row.provider.hint),
792        };
793    }
794    if row.provider.credential == Credential::ApiKey && !wizard.reveal {
795        catalog::redact(&row.value)
796    } else {
797        row.value.clone()
798    }
799}
800
801/// The verification result line for one provider.
802fn status_line(wizard: &Wizard, index: usize) -> Line<'static> {
803    let row = &wizard.providers[index];
804    if row.checking {
805        let frame = SPINNER[(wizard.ticks as usize) % SPINNER.len()];
806        return Line::from(vec![
807            Span::styled(format!("{frame} "), Style::default().fg(C_ACCENT)),
808            Span::styled("checking…", Style::default().fg(C_MUTED)),
809        ]);
810    }
811    match &row.outcome {
812        super::verify::Outcome::Skipped => {
813            Line::from(Span::styled("not checked yet", Style::default().fg(C_DIM)))
814        }
815        super::verify::Outcome::Reachable { .. } => Line::from(vec![
816            Span::styled(format!("{GLYPH_COMPLETE} "), Style::default().fg(C_SUCCESS)),
817            Span::styled(row.outcome.summary(), Style::default().fg(C_SUCCESS)),
818        ]),
819        super::verify::Outcome::Failed { .. } => Line::from(vec![
820            Span::styled(format!("{GLYPH_ERROR} "), Style::default().fg(C_ERROR)),
821            Span::styled(row.outcome.summary(), Style::default().fg(C_ERROR)),
822        ]),
823    }
824}
825
826fn build_fields(wizard: &Wizard) -> Screen {
827    let mut screen = Screen::default();
828    for (index, field) in wizard.fields().iter().enumerate() {
829        let selected = index == wizard.cursor;
830        let hint = match &field.value {
831            FieldValue::Bool(_) => "enter/space",
832            FieldValue::Choice { .. } => "enter/← →",
833            _ => "enter",
834        };
835        let mut spans = vec![
836            Span::styled(
837                if selected { "› " } else { "  " },
838                Style::default().fg(C_ACCENT),
839            ),
840            Span::styled(format!("{:<28}", field.label), name_style(selected)),
841        ];
842        match &wizard.edit {
843            Some(edit) if edit.target == super::state::EditTarget::Field(index) => {
844                spans.extend(edit.line.display_spans(wizard.reveal).spans);
845            }
846            _ => spans.push(Span::styled(
847                field.value.display(),
848                Style::default().fg(C_ACCENT),
849            )),
850        }
851        spans.push(Span::styled(
852            format!("   [{hint}]"),
853            Style::default().fg(C_DIM),
854        ));
855        screen.row();
856        screen.push(Line::from(spans));
857        screen.push(Line::from(Span::styled(
858            format!("    {}", field.help),
859            Style::default().fg(C_DIM),
860        )));
861    }
862    screen.finish(wizard)
863}
864
865fn build_agents(wizard: &Wizard) -> Screen {
866    let mut screen = Screen::default();
867    for (index, row) in wizard.agents.iter().enumerate() {
868        let mark = if row.selected {
869            GLYPH_COMPLETE
870        } else {
871            GLYPH_PENDING
872        };
873        let action = row.action.label(row.agent.version);
874        // Dim for "nothing to do", and for a locally edited install too:
875        // it is offered, not urged, because reinstalling destroys the edit.
876        let action_style = if row.action.preselect() {
877            Style::default().fg(C_ACCENT)
878        } else {
879            Style::default().fg(C_DIM)
880        };
881        screen.row();
882        screen.push(Line::from(vec![
883            Span::styled(
884                format!("{mark} "),
885                Style::default().fg(if row.selected { C_SUCCESS } else { C_DIM }),
886            ),
887            Span::styled(
888                format!("{:<22}", row.agent.name),
889                name_style(index == wizard.cursor),
890            ),
891            Span::styled(action, action_style),
892        ]));
893    }
894    screen.finish(wizard)
895}
896
897fn build_mcp(wizard: &Wizard) -> Screen {
898    let mut screen = Screen::default();
899    for (index, row) in wizard.mcp.iter().enumerate() {
900        let mark = if row.selected {
901            GLYPH_COMPLETE
902        } else {
903            GLYPH_PENDING
904        };
905        let mut detail = vec![Span::styled(
906            format!("    from {}", row.source),
907            Style::default().fg(C_DIM),
908        )];
909        if !row.candidate.scope.is_empty() {
910            detail.push(Span::styled(
911                format!(" · {}", row.candidate.scope),
912                Style::default().fg(C_DIM),
913            ));
914        }
915        if row.collides {
916            detail.push(Span::styled(
917                format!(" · already configured; would be added as {}", row.name),
918                Style::default().fg(C_WARN),
919            ));
920        }
921        if !row.candidate.inline_secrets.is_empty() {
922            detail.push(Span::styled(
923                format!(
924                    " · carries a literal secret in {}",
925                    row.candidate.inline_secrets.join(", ")
926                ),
927                Style::default().fg(C_WARN),
928            ));
929        }
930        let endpoint = row
931            .candidate
932            .config
933            .url
934            .clone()
935            .or_else(|| row.candidate.config.command.clone())
936            .unwrap_or_default();
937        screen.row();
938        screen.push(Line::from(vec![
939            Span::styled(
940                format!("{mark} "),
941                Style::default().fg(if row.selected { C_SUCCESS } else { C_DIM }),
942            ),
943            Span::styled(
944                format!("{:<22}", row.candidate.config.name),
945                name_style(index == wizard.cursor),
946            ),
947            Span::styled(endpoint, Style::default().fg(C_MUTED)),
948        ]));
949        screen.push(Line::from(detail));
950    }
951
952    for error in &wizard.mcp_scan_errors {
953        screen.push(Line::from(Span::styled(
954            format!("{GLYPH_ERROR} {error}"),
955            Style::default().fg(C_WARN),
956        )));
957    }
958    screen.finish(wizard)
959}
960
961fn build_review(wizard: &Wizard) -> Screen {
962    let mut lines = vec![Line::from(Span::styled(
963        "About to write:",
964        Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD),
965    ))];
966    for change in wizard.review_lines() {
967        lines.push(Line::from(vec![
968            Span::styled("  • ", Style::default().fg(C_ACCENT)),
969            Span::styled(change, Style::default().fg(C_WHITE)),
970        ]));
971    }
972
973    let secrets = wizard.selected_inline_secrets();
974    if !secrets.is_empty() {
975        lines.push(Line::from(""));
976        lines.push(Line::from(Span::styled(
977            "These imported servers carry a credential written out in full, which",
978            Style::default().fg(C_WARN),
979        )));
980        lines.push(Line::from(Span::styled(
981            "would be copied into your Leviath config:",
982            Style::default().fg(C_WARN),
983        )));
984        for entry in secrets {
985            lines.push(Line::from(Span::styled(
986                format!("  • {entry}"),
987                Style::default().fg(C_WARN),
988            )));
989        }
990    }
991
992    let failures: Vec<String> = wizard
993        .providers
994        .iter()
995        .filter(|r| r.selected && r.outcome.is_failure())
996        .map(|r| format!("{}: {}", r.provider.display, r.outcome.summary()))
997        .collect();
998    if !failures.is_empty() {
999        lines.push(Line::from(""));
1000        lines.push(Line::from(Span::styled(
1001            "Did not verify (saving anyway is fine):",
1002            Style::default().fg(C_ERROR),
1003        )));
1004        for failure in failures {
1005            lines.push(Line::from(Span::styled(
1006                format!("  • {failure}"),
1007                Style::default().fg(C_ERROR),
1008            )));
1009        }
1010    }
1011
1012    if wizard
1013        .providers
1014        .iter()
1015        .any(|r| r.selected && r.provider.id == "claude-code")
1016    {
1017        lines.push(Line::from(""));
1018        for warning in [
1019            "Claude Code transport: Anthropic's terms may prohibit third-party use of",
1020            "subscription auth without prior approval. By enabling it you accept",
1021            "responsibility for compliance with their terms.",
1022        ] {
1023            lines.push(Line::from(Span::styled(
1024                warning,
1025                Style::default().fg(C_WARN),
1026            )));
1027        }
1028    }
1029
1030    Screen {
1031        lines,
1032        rows: Vec::new(),
1033    }
1034    .finish(wizard)
1035}
1036
1037/// The footer's key hints for the wizard's current mode.
1038fn footer_hints(wizard: &Wizard) -> Vec<Hint> {
1039    if wizard.confirm.is_some() {
1040        return vec![
1041            hint("←→", "choose"),
1042            hint("enter", "confirm"),
1043            hint("esc", "cancel"),
1044        ];
1045    }
1046    if wizard.picker.is_some() {
1047        return vec![
1048            hint("type", "search"),
1049            hint("↑↓", "move"),
1050            hint("enter/click", "choose"),
1051            hint("esc", "keep what it was"),
1052        ];
1053    }
1054    if wizard.edit.is_some() {
1055        return vec![
1056            hint("enter", "save"),
1057            hint("esc", "cancel"),
1058            hint("←→", "move cursor"),
1059        ];
1060    }
1061    match wizard.step {
1062        Step::Welcome => vec![hint("enter", "begin"), hint("?", "help"), hint("q", "quit")],
1063        Step::Providers => vec![
1064            hint("↑↓", "move"),
1065            hint("space/enter", "select"),
1066            hint("o", "signup"),
1067            hint("v", "check"),
1068            hint("tab", "next"),
1069            hint("q", "quit"),
1070        ],
1071        Step::ProviderDetail => vec![
1072            hint("enter", "edit"),
1073            hint("v", "check"),
1074            hint("o", "signup"),
1075            hint("tab", "next"),
1076            hint("esc", "back"),
1077            hint("q", "quit"),
1078        ],
1079        Step::Defaults | Step::Limits => vec![
1080            hint("↑↓", "move"),
1081            hint("enter", "change"),
1082            hint("←→", "cycle"),
1083            hint("tab", "next"),
1084            hint("esc", "back"),
1085            hint("q", "quit"),
1086        ],
1087        Step::Agents | Step::Mcp => vec![
1088            hint("↑↓", "move"),
1089            hint("space/enter", "select"),
1090            hint("tab", "next"),
1091            hint("esc", "back"),
1092            hint("q", "quit"),
1093        ],
1094        Step::Review => vec![
1095            hint("enter", "apply"),
1096            hint("v", "re-check"),
1097            hint("esc", "back"),
1098            hint("q", "quit"),
1099        ],
1100    }
1101}
1102
1103fn draw_footer(frame: &mut Frame, area: Rect, wizard: &Wizard) {
1104    let hints = footer_hints(wizard);
1105    let message = wizard.message.as_deref().map(|m| (m, C_WARN));
1106    draw_hint_bar(frame, area, message, &hints, true);
1107}
1108
1109/// Highlight style for the row under the cursor.
1110fn name_style(selected: bool) -> Style {
1111    if selected {
1112        Style::default().fg(C_ACTIVE).add_modifier(Modifier::BOLD)
1113    } else {
1114        Style::default().fg(C_WHITE)
1115    }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121    use crate::commands::setup::state::{Edit, EditTarget, FieldValue, Wizard};
1122    use crate::config::Config;
1123    use crate::tui::TestBackendHarness;
1124    use ratatui::Terminal;
1125
1126    /// Render one frame and return every non-blank line of the buffer, so a
1127    /// test can assert on what a user would actually read.
1128    fn rendered(wizard: &Wizard) -> String {
1129        let mut terminal = Terminal::new(TestBackendHarness::new(140, 44)).unwrap();
1130        terminal.draw(|frame| draw(frame, wizard)).unwrap();
1131        terminal.backend().text()
1132    }
1133
1134    /// A provider blurb on a narrow window must wrap, not clip: the tail of
1135    /// the sentence (here the transport's "cannot be disabled" caveat) has to
1136    /// reach the screen.
1137    #[test]
1138    fn narrow_window_wraps_provider_blurbs_instead_of_clipping() {
1139        let (_dir, mut w) = wizard();
1140        w.enter(Step::Providers);
1141        let mut terminal = Terminal::new(TestBackendHarness::new(48, 44)).unwrap();
1142        terminal.draw(|frame| draw(frame, &w)).unwrap();
1143        let screen = terminal.backend().text();
1144        assert!(
1145            screen.contains("disabled"),
1146            "the blurb tail must survive a 48-column window:\n{screen}"
1147        );
1148    }
1149
1150    /// The text of one wrapped line, span styles collapsed away.
1151    fn wrapped_text(line: &Line<'static>, width: usize) -> Vec<String> {
1152        wrap_line(line, width)
1153            .iter()
1154            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
1155            .collect()
1156    }
1157
1158    #[test]
1159    fn wrap_line_breaks_at_words_and_hard_breaks_one_that_never_fits() {
1160        let plain = Line::from("alpha beta gamma");
1161        assert_eq!(wrapped_text(&plain, 11), ["alpha beta", "gamma"]);
1162        // Nothing to do when it already fits, and the line comes back whole.
1163        assert_eq!(wrapped_text(&plain, 40), ["alpha beta gamma"]);
1164        // One word wider than the pane hard-breaks by characters.
1165        assert_eq!(
1166            wrapped_text(&Line::from("abcdefghij"), 4),
1167            ["abcd", "efgh", "ij"]
1168        );
1169        // Multibyte characters break on char boundaries, never mid-character.
1170        assert_eq!(
1171            wrapped_text(&Line::from("\u{65e5}\u{672c}\u{8a9e}\u{3067}\u{3059}"), 2),
1172            ["\u{65e5}\u{672c}", "\u{8a9e}\u{3067}", "\u{3059}"]
1173        );
1174        // A degenerate zero width still terminates.
1175        assert_eq!(wrapped_text(&Line::from("ab"), 0), ["a", "b"]);
1176        // Padding that fits is kept, and dropped from the end of a row it
1177        // would otherwise trail off.
1178        assert_eq!(wrapped_text(&Line::from("ab  cdef"), 4), ["ab", "cdef"]);
1179        // Whitespace that lands at the start of a continuation is dropped
1180        // too: the break already stood in for it. Across a span boundary that
1181        // is a whole run arriving with a row already broken under it.
1182        assert_eq!(wrapped_text(&Line::from("aaaa  bbbb"), 4), ["aaaa", "bbbb"]);
1183        assert_eq!(
1184            wrapped_text(&Line::from(vec![Span::raw("aaaa "), Span::raw(" bbbb")]), 4),
1185            ["aaaa", "bbbb"]
1186        );
1187        // Text ending exactly at a break leaves nothing to close.
1188        assert_eq!(wrapped_text(&Line::from("aaaa "), 4), ["aaaa"]);
1189        assert_eq!(wrapped_text(&Line::from("aaaa bb"), 4), ["aaaa", "bb"]);
1190    }
1191
1192    /// An indented line whose word cannot fit even after the indent breaks the
1193    /// word and keeps indenting what follows.
1194    #[test]
1195    fn wrap_line_hard_breaks_under_an_indent_it_can_afford() {
1196        assert_eq!(
1197            wrapped_text(&Line::from("  abcdefghijklmno"), 10),
1198            ["  abcdefgh", "  ijklmno"]
1199        );
1200    }
1201
1202    /// An indented help line keeps its indent on every row it folds onto, so a
1203    /// wrapped help string still reads as belonging to the field above it.
1204    #[test]
1205    fn wrap_line_hangs_continuations_under_the_indent() {
1206        let indented = Line::from("    alpha beta gamma delta");
1207        assert_eq!(
1208            wrapped_text(&indented, 14),
1209            ["    alpha beta", "    gamma", "    delta"]
1210        );
1211        // Unless the indent would leave no useful width, in which case the text
1212        // is worth more than the alignment and continuations start at column 0.
1213        assert_eq!(
1214            wrapped_text(&indented, 8),
1215            ["    alph", "a beta", "gamma", "delta"]
1216        );
1217    }
1218
1219    /// Styles survive the fold, and column padding that no longer fits is
1220    /// dropped at the break rather than pushed onto the next row.
1221    #[test]
1222    fn wrap_line_keeps_styles_and_drops_padding_at_a_break() {
1223        let line = Line::from(vec![
1224            Span::styled("name", Style::default().fg(C_WHITE)),
1225            Span::styled("        ", Style::default()),
1226            Span::styled("value", Style::default().fg(C_ACCENT)),
1227        ]);
1228        let wrapped = wrap_line(&line, 8);
1229        assert_eq!(
1230            wrapped
1231                .iter()
1232                .map(|l| l
1233                    .spans
1234                    .iter()
1235                    .map(|s| s.content.as_ref())
1236                    .collect::<String>())
1237                .collect::<Vec<_>>(),
1238            ["name", "value"]
1239        );
1240        assert_eq!(wrapped[1].spans[0].style.fg, Some(C_ACCENT));
1241    }
1242
1243    /// Draw into a window of a given size and return what it says.
1244    fn rendered_at(wizard: &Wizard, width: u16, height: u16) -> String {
1245        let mut terminal = Terminal::new(TestBackendHarness::new(width, height)).unwrap();
1246        terminal.draw(|frame| draw(frame, wizard)).unwrap();
1247        terminal.backend().text()
1248    }
1249
1250    /// The tuning screen has thirteen two-line fields, which is more than most
1251    /// windows are tall. It used to render as a `List` that simply stopped at
1252    /// the bottom of the pane, so the last fields and the Continue button were
1253    /// unreachable with no sign they existed.
1254    #[test]
1255    fn a_short_window_can_still_reach_the_last_field_and_the_button() {
1256        let (_dir, mut w) = wizard();
1257        w.show_advanced = true;
1258        w.enter(Step::Limits);
1259
1260        let top = rendered_at(&w, 90, 20);
1261        assert!(top.contains("Max concurrent inferences"), "{top}");
1262        assert!(
1263            !top.contains("Max bytes one run may write"),
1264            "the far end of the form is not on the first screenful:\n{top}"
1265        );
1266        // The scrollbar is what says there is more, since nothing else can.
1267        assert!(top.contains('\u{2193}'), "no scrollbar drawn:\n{top}");
1268
1269        w.scroll_end();
1270        let bottom = rendered_at(&w, 90, 20);
1271        assert!(bottom.contains("Max bytes one run may write"), "{bottom}");
1272        assert!(
1273            bottom.contains("Continue:"),
1274            "the button has to be reachable:\n{bottom}"
1275        );
1276    }
1277
1278    /// Paging moves the selection as well as the view, so the two can never
1279    /// end up looking at different parts of the form.
1280    #[test]
1281    fn paging_moves_the_selection_and_the_view_together() {
1282        let (_dir, mut w) = wizard();
1283        w.show_advanced = true;
1284        w.enter(Step::Limits);
1285
1286        w.scroll_by(Wizard::PAGE);
1287        assert_eq!(w.cursor, Wizard::PAGE as usize);
1288        let screen = rendered_at(&w, 90, 20);
1289        assert!(
1290            screen.contains("Finished run retention"),
1291            "the newly selected row has to be on screen:\n{screen}"
1292        );
1293
1294        // Moving the selection back up pulls the view with it, even though the
1295        // scroll offset was left pointing at the far end of the form.
1296        w.scroll_end();
1297        w.move_cursor(-100);
1298        let back = rendered_at(&w, 90, 20);
1299        assert!(back.contains("Max concurrent inferences"), "{back}");
1300
1301        w.scroll_home();
1302        assert_eq!(w.cursor, 0);
1303        assert!(rendered_at(&w, 90, 20).contains("Max concurrent inferences"));
1304    }
1305
1306    /// A cursor past the end of the rows draws the top of the screen rather
1307    /// than panicking. Nothing in the wizard puts it there, but tests do, and
1308    /// a render is never the right place to discover an inconsistency.
1309    #[test]
1310    fn a_cursor_past_the_last_row_still_draws() {
1311        let (_dir, mut w) = wizard();
1312        w.enter(Step::Providers);
1313        w.cursor = 999;
1314        let screen = rendered_at(&w, 90, 20);
1315        assert!(screen.contains("Providers"), "{screen}");
1316    }
1317
1318    /// Review has nothing to select, so there the offset moves on its own
1319    /// rather than being pinned to a cursor that cannot move.
1320    #[test]
1321    fn a_screen_with_no_rows_scrolls_by_offset() {
1322        let (_dir, mut w) = wizard();
1323        w.enter(Step::Review);
1324        assert_eq!(w.row_count(), 0);
1325
1326        w.scroll_by(Wizard::PAGE);
1327        assert_eq!(w.scroll, Wizard::PAGE as usize);
1328        w.scroll_by(-Wizard::PAGE * 4);
1329        assert_eq!(w.scroll, 0, "scrolling up past the top stops at the top");
1330    }
1331
1332    /// Below a floor there is no layout left worth drawing, and half a
1333    /// bordered pane reads as a crash rather than a small window.
1334    #[test]
1335    fn a_window_under_the_floor_says_so_instead_of_drawing_wreckage() {
1336        let (_dir, w) = wizard();
1337        let tiny = rendered_at(&w, 20, 5);
1338        assert!(tiny.contains("Window too small"), "{tiny}");
1339
1340        // Just above the floor it draws, and drops the breadcrumb to spend the
1341        // rows on content instead.
1342        let small = rendered_at(&w, 60, 12);
1343        assert!(small.contains("Get started"), "{small}");
1344        assert!(
1345            !small.contains("\u{203a} Providers"),
1346            "the breadcrumb is what gives way first:\n{small}"
1347        );
1348    }
1349
1350    /// The chooser draws its prose, its search box and its rows, and a filter
1351    /// that matches nothing says so rather than showing an empty pane.
1352    #[test]
1353    fn the_chooser_draws_the_explanation_the_field_had_no_room_for() {
1354        let (_dir, mut w) = wizard();
1355        w.providers[0].selected = true;
1356        w.providers[0].outcome = crate::commands::setup::verify::Outcome::Reachable {
1357            models: vec!["claude-opus-4".to_string()],
1358        };
1359        w.enter(Step::Defaults);
1360        w.cursor = 1;
1361        w.open_picker("Default model", w.defaults[1].value.options().to_vec(), 0);
1362
1363        let screen = rendered(&w);
1364        assert!(screen.contains("Default model"), "{screen}");
1365        assert!(
1366            screen.contains("never sent to a different provider"),
1367            "the precedence prose is the point of the screen:\n{screen}"
1368        );
1369        assert!(screen.contains("claude-opus-4"), "{screen}");
1370        assert!(screen.contains("Search"), "{screen}");
1371
1372        // Nothing matching is a state worth naming.
1373        w.picker.as_mut().expect("open").query =
1374            crate::tui::widgets::line_edit::LineEdit::new("zzz", false);
1375        assert!(rendered(&w).contains("Nothing matches that."));
1376    }
1377
1378    /// The chooser fills most of the window, so a short one still gets a list
1379    /// rather than only a heading.
1380    #[test]
1381    fn the_chooser_survives_a_short_window() {
1382        let (_dir, mut w) = wizard();
1383        w.enter(Step::Defaults);
1384        w.open_picker(
1385            "Default provider",
1386            w.defaults[0].value.options().to_vec(),
1387            0,
1388        );
1389
1390        let screen = rendered_at(&w, 70, 14);
1391        assert!(screen.contains("anthropic"), "{screen}");
1392    }
1393
1394    /// A window too short to hold the chooser's frame has nothing in it to
1395    /// click, and neither does the space outside its list.
1396    #[test]
1397    fn a_window_too_short_for_the_chooser_declines_to_draw_it() {
1398        let (_dir, mut w) = wizard();
1399        w.enter(Step::Defaults);
1400        w.open_picker(
1401            "Default provider",
1402            w.defaults[0].value.options().to_vec(),
1403            0,
1404        );
1405        let picker = w.picker.as_ref().expect("open");
1406
1407        // `draw` refuses to draw anything under its own floor, so this size
1408        // only reaches the hit test - which a real 5-row terminal can.
1409        assert_eq!(picker_row_at(Rect::new(0, 0, 60, 5), picker, 3), None);
1410        // A click outside the list is not a row either.
1411        assert_eq!(picker_row_at(Rect::new(0, 0, 90, 40), picker, 1), None);
1412    }
1413
1414    /// Hit-testing agrees with drawing about what is on screen: nothing below
1415    /// the last line is a row, and a window under the floor has no rows at all.
1416    #[test]
1417    fn a_click_below_the_content_is_not_the_nearest_row() {
1418        let (_dir, mut w) = wizard();
1419        w.enter(Step::Welcome);
1420        let area = Rect::new(0, 0, 90, 40);
1421
1422        // Welcome is a handful of lines in a forty-row window, so most of the
1423        // pane is empty space under them.
1424        assert_eq!(row_at(area, &w, 4, 34), None);
1425        // And below the floor there is no layout to resolve against.
1426        assert_eq!(row_at(Rect::new(0, 0, 10, 4), &w, 2, 2), None);
1427    }
1428
1429    fn wizard() -> (tempfile::TempDir, Wizard) {
1430        let dir = tempfile::tempdir().unwrap();
1431        let wizard = crate::commands::setup::state::tests::test_wizard(dir.path());
1432        (dir, wizard)
1433    }
1434
1435    #[test]
1436    fn every_step_draws_without_panicking_and_names_itself() {
1437        // The cheapest guard against a layout arm that only blows up on the one
1438        // screen nobody opened during testing.
1439        let dir = tempfile::tempdir().unwrap();
1440        let mut w = Wizard::new(
1441            Config::default(),
1442            &|_| None,
1443            vec![(
1444                "Claude Code".to_string(),
1445                crate::commands::setup::import::Candidate {
1446                    config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
1447                    scope: "/repo".to_string(),
1448                    inline_secrets: vec!["API_TOKEN".to_string()],
1449                },
1450            )],
1451            vec!["Zed: unreadable".to_string()],
1452            dir.path(),
1453            std::sync::Arc::new(|_| true),
1454        );
1455        w.providers[0].selected = true;
1456
1457        for step in Step::ALL {
1458            w.enter(step);
1459            let screen = rendered(&w);
1460            assert!(
1461                screen.contains(step.title()),
1462                "{step:?} did not name itself:\n{screen}"
1463            );
1464        }
1465    }
1466
1467    #[test]
1468    fn a_tiny_terminal_still_draws() {
1469        // Layout constraints that assume room can panic on a small window.
1470        let (_dir, w) = wizard();
1471        let mut terminal = Terminal::new(TestBackendHarness::new(20, 8)).unwrap();
1472
1473        assert!(terminal.draw(|frame| draw(frame, &w)).is_ok());
1474    }
1475
1476    #[test]
1477    fn the_welcome_screen_reports_what_is_already_there() {
1478        let (_dir, mut w) = wizard();
1479        assert!(rendered(&w).contains("Nothing is configured yet"));
1480
1481        w.providers[0].selected = true;
1482        let screen = rendered(&w);
1483        assert!(screen.contains("Already configured"));
1484        assert!(screen.contains("Anthropic"));
1485    }
1486
1487    #[test]
1488    fn the_provider_list_marks_where_each_credential_came_from() {
1489        let dir = tempfile::tempdir().unwrap();
1490        let mut w = Wizard::new(
1491            Config::default(),
1492            &|name| (name == "ANTHROPIC_API_KEY").then(|| "sk-ant-env".to_string()),
1493            Vec::new(),
1494            Vec::new(),
1495            dir.path(),
1496            std::sync::Arc::new(|_| true),
1497        );
1498        w.providers[1].selected = true;
1499        w.providers[1].value = "sk-oai".to_string();
1500        w.enter(Step::Providers);
1501
1502        let screen = rendered(&w);
1503        assert!(
1504            screen.contains("$ANTHROPIC_API_KEY"),
1505            "the environment source must be visible:\n{screen}"
1506        );
1507        assert!(screen.contains("(set)"), "{screen}");
1508        assert!(!screen.contains("sk-oai"), "a key leaked:\n{screen}");
1509    }
1510
1511    #[test]
1512    fn a_stored_key_is_redacted_until_ctrl_r() {
1513        let (_dir, mut w) = wizard();
1514        w.providers[0].selected = true;
1515        w.providers[0].value = "sk-ant-secret-value-here".to_string();
1516        w.enter(Step::ProviderDetail);
1517
1518        let hidden = rendered(&w);
1519        // Last four characters, not the first eight - see `catalog::redact`.
1520        assert!(hidden.contains("****here"), "{hidden}");
1521        assert!(
1522            !hidden.contains("sk-ant-s"),
1523            "issuer prefix leaked:\n{hidden}"
1524        );
1525        assert!(
1526            !hidden.contains("secret-value"),
1527            "the key leaked:\n{hidden}"
1528        );
1529
1530        w.reveal = true;
1531        assert!(rendered(&w).contains("sk-ant-secret-value-here"));
1532    }
1533
1534    #[test]
1535    fn a_key_being_typed_is_masked_until_revealed() {
1536        let (_dir, mut w) = wizard();
1537        w.providers[0].selected = true;
1538        w.enter(Step::ProviderDetail);
1539        w.edit = Some(Edit {
1540            target: EditTarget::Credential(0),
1541            line: crate::tui::widgets::line_edit::LineEdit::new("sk-typing".to_string(), true),
1542        });
1543
1544        let hidden = rendered(&w);
1545        assert!(hidden.contains("•••"), "{hidden}");
1546        assert!(!hidden.contains("sk-typing"), "the key leaked:\n{hidden}");
1547
1548        w.reveal = true;
1549        assert!(rendered(&w).contains("sk-typing"));
1550    }
1551
1552    #[test]
1553    fn an_empty_credential_shows_its_placeholder_or_its_source() {
1554        let (_dir, mut w) = wizard();
1555        w.providers[0].selected = true;
1556        w.enter(Step::ProviderDetail);
1557        assert!(rendered(&w).contains("sk-ant-..."));
1558
1559        w.providers[0].from_env = Some("ANTHROPIC_API_KEY");
1560        let screen = rendered(&w);
1561        assert!(screen.contains("from the environment"));
1562        assert!(
1563            screen.contains("will not be written"),
1564            "the user must know the key stays where they put it:\n{screen}"
1565        );
1566    }
1567
1568    #[test]
1569    fn a_base_url_is_never_masked() {
1570        // It is not a secret, and hiding it would just be annoying.
1571        let (_dir, mut w) = wizard();
1572        let ollama = w
1573            .providers
1574            .iter()
1575            .position(|r| r.provider.id == "ollama")
1576            .expect("ollama is offered");
1577        w.providers[ollama].selected = true;
1578        w.providers[ollama].value = "http://box:11434".to_string();
1579        w.enter(Step::ProviderDetail);
1580
1581        assert!(rendered(&w).contains("http://box:11434"));
1582    }
1583
1584    #[test]
1585    fn every_verification_state_is_drawn_distinctly() {
1586        let (_dir, mut w) = wizard();
1587        w.providers[0].selected = true;
1588        w.providers[0].value = "sk-ant".to_string();
1589        w.enter(Step::ProviderDetail);
1590
1591        assert!(rendered(&w).contains("not checked yet"));
1592
1593        w.providers[0].checking = true;
1594        assert!(rendered(&w).contains("checking"));
1595
1596        w.providers[0].checking = false;
1597        w.providers[0].outcome = crate::commands::setup::verify::Outcome::Reachable {
1598            models: vec!["a".into(), "b".into()],
1599        };
1600        assert!(rendered(&w).contains("2 models"));
1601
1602        w.providers[0].outcome = crate::commands::setup::verify::Outcome::Failed {
1603            message: "rejected - check the key".into(),
1604        };
1605        assert!(rendered(&w).contains("rejected"));
1606    }
1607
1608    #[test]
1609    fn the_claude_code_card_shows_its_effort_and_its_caveat() {
1610        let (_dir, mut w) = wizard();
1611        let index = w
1612            .providers
1613            .iter()
1614            .position(|r| r.provider.id == "claude-code")
1615            .expect("the transport is offered");
1616        w.providers[index].selected = true;
1617        w.enter(Step::ProviderDetail);
1618
1619        let screen = rendered(&w);
1620        assert!(screen.contains("Reasoning effort"));
1621        assert!(
1622            screen.contains("email"),
1623            "the privacy cost must be on screen"
1624        );
1625        assert!(
1626            screen.contains("terms"),
1627            "the terms-of-service risk must be on screen:\n{screen}"
1628        );
1629    }
1630
1631    #[test]
1632    fn the_review_screen_warns_about_the_claude_code_terms() {
1633        let (_dir, mut w) = wizard();
1634        let index = w
1635            .providers
1636            .iter()
1637            .position(|r| r.provider.id == "claude-code")
1638            .expect("the transport is offered");
1639        w.enter(Step::Review);
1640        assert!(
1641            !rendered(&w).contains("Claude Code transport: Anthropic"),
1642            "the warning is only for a setup that enables it"
1643        );
1644
1645        w.providers[index].selected = true;
1646        let screen = rendered(&w);
1647        assert!(
1648            screen.contains("Claude Code transport: Anthropic"),
1649            "{screen}"
1650        );
1651        assert!(screen.contains("responsibility for compliance"), "{screen}");
1652    }
1653
1654    #[test]
1655    fn the_tos_confirmation_dialog_draws_over_the_review_screen() {
1656        let (_dir, mut w) = wizard();
1657        let index = w
1658            .providers
1659            .iter()
1660            .position(|r| r.provider.id == "claude-code")
1661            .expect("the transport is offered");
1662        w.providers[index].selected = true;
1663        w.enter(Step::Review);
1664        w.open_tos_confirm();
1665
1666        let screen = rendered(&w);
1667        assert!(
1668            screen.contains("terms of service"),
1669            "dialog title missing:\n{screen}"
1670        );
1671        assert!(
1672            screen.contains("[ Accept and save ]"),
1673            "the affirmative button is missing:\n{screen}"
1674        );
1675        assert!(
1676            screen.contains("[ Cancel ]"),
1677            "the safe button is missing:\n{screen}"
1678        );
1679    }
1680
1681    #[test]
1682    fn the_quit_and_no_provider_dialogs_draw_their_buttons() {
1683        let (_dir, mut w) = wizard();
1684        w.open_quit_confirm();
1685        let screen = rendered(&w);
1686        assert!(screen.contains("Quit setup?"), "{screen}");
1687        assert!(screen.contains("[ Stay ]"), "{screen}");
1688
1689        w.confirm = None;
1690        w.open_no_providers_confirm();
1691        let screen = rendered(&w);
1692        assert!(screen.contains("No providers selected"), "{screen}");
1693        assert!(screen.contains("[ Go back ]"), "{screen}");
1694        assert!(screen.contains("[ Continue anyway ]"), "{screen}");
1695    }
1696
1697    #[test]
1698    fn the_credential_screen_draws_nothing_when_no_provider_is_selected() {
1699        let (_dir, mut w) = wizard();
1700        w.enter(Step::ProviderDetail);
1701
1702        // Just the chrome and the Continue button, no panic.
1703        assert!(rendered(&w).contains("Credentials"));
1704    }
1705
1706    #[test]
1707    fn the_credential_screen_moves_the_focus_marker_onto_the_continue_button() {
1708        let (_dir, mut w) = wizard();
1709        w.providers[0].selected = true;
1710        w.enter(Step::ProviderDetail);
1711
1712        // Cursor on the credential row: the row carries the marker.
1713        assert!(rendered(&w).contains("› API key:"));
1714
1715        // Cursor on the Continue button: the row loses it, the button gains it.
1716        w.cursor = w.row_count();
1717        let screen = rendered(&w);
1718        assert!(!screen.contains("› API key:"), "{screen}");
1719        assert!(screen.contains("› [ Continue: Defaults ]"), "{screen}");
1720    }
1721
1722    #[test]
1723    fn a_field_being_edited_shows_the_buffer_not_the_stored_value() {
1724        let (_dir, mut w) = wizard();
1725        w.enter(Step::Limits);
1726        w.edit = Some(Edit {
1727            target: EditTarget::Field(0),
1728            line: crate::tui::widgets::line_edit::LineEdit::new("42".to_string(), false),
1729        });
1730
1731        assert!(rendered(&w).contains("42"));
1732    }
1733
1734    #[test]
1735    fn each_field_kind_advertises_the_key_that_changes_it() {
1736        let (_dir, mut w) = wizard();
1737        w.enter(Step::Limits);
1738        let screen = rendered(&w);
1739        assert!(screen.contains("[enter]"), "numbers are typed");
1740        assert!(screen.contains("[enter/space]"), "booleans are toggled");
1741
1742        w.providers[0].selected = true;
1743        w.enter(Step::Defaults);
1744        assert!(rendered(&w).contains("enter/← →"), "choices are cycled");
1745    }
1746
1747    #[test]
1748    fn the_agent_list_shows_what_each_row_would_do() {
1749        let dir = tempfile::tempdir().unwrap();
1750        crate::bundled::install_bundled(&crate::bundled::BUNDLED_AGENTS[0], dir.path()).unwrap();
1751        let mut w = crate::commands::setup::state::tests::test_wizard(dir.path());
1752        w.enter(Step::Agents);
1753
1754        let screen = rendered(&w);
1755        assert!(screen.contains("up to date"));
1756        // Read the expected version off the bundled agent rather than spelling
1757        // it out, so bumping a blueprint does not break this test.
1758        let not_installed = &crate::bundled::BUNDLED_AGENTS[1];
1759        assert!(screen.contains(&format!("install {}", not_installed.version)));
1760    }
1761
1762    #[test]
1763    fn the_mcp_list_flags_collisions_scopes_and_inline_secrets() {
1764        let dir = tempfile::tempdir().unwrap();
1765        let base = Config {
1766            mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![])],
1767            ..Config::default()
1768        };
1769        let mut candidate = crate::commands::setup::import::Candidate {
1770            config: leviath_mcp::MCPServerConfig::http("fs", "https://x.test/mcp"),
1771            scope: "/repo".to_string(),
1772            inline_secrets: vec!["Authorization".to_string()],
1773        };
1774        candidate.config.name = "fs".to_string();
1775        let mut w = Wizard::new(
1776            base,
1777            &|_| None,
1778            vec![("Cursor".to_string(), candidate)],
1779            vec!["Zed: couldn't parse this".to_string()],
1780            dir.path(),
1781            std::sync::Arc::new(|_| true),
1782        );
1783        w.enter(Step::Mcp);
1784
1785        let screen = rendered(&w);
1786        assert!(screen.contains("from Cursor"));
1787        assert!(screen.contains("/repo"));
1788        assert!(screen.contains("already configured"));
1789        assert!(screen.contains("fs-2"), "the free name is shown");
1790        assert!(screen.contains("literal secret"));
1791        assert!(screen.contains("Zed"), "the unreadable source is reported");
1792        assert!(screen.contains("https://x.test/mcp"));
1793    }
1794
1795    #[test]
1796    fn an_imported_stdio_server_shows_its_command() {
1797        let dir = tempfile::tempdir().unwrap();
1798        let mut w = Wizard::new(
1799            Config::default(),
1800            &|_| None,
1801            vec![(
1802                "Codex".to_string(),
1803                crate::commands::setup::import::Candidate {
1804                    config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
1805                    scope: String::new(),
1806                    inline_secrets: Vec::new(),
1807                },
1808            )],
1809            Vec::new(),
1810            dir.path(),
1811            std::sync::Arc::new(|_| true),
1812        );
1813        w.enter(Step::Mcp);
1814
1815        assert!(rendered(&w).contains("npx"));
1816    }
1817
1818    #[test]
1819    fn the_review_screen_lists_changes_and_both_kinds_of_warning() {
1820        let dir = tempfile::tempdir().unwrap();
1821        let mut w = Wizard::new(
1822            Config::default(),
1823            &|_| None,
1824            vec![(
1825                "Cursor".to_string(),
1826                crate::commands::setup::import::Candidate {
1827                    config: leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
1828                    scope: String::new(),
1829                    inline_secrets: vec!["API_TOKEN".to_string()],
1830                },
1831            )],
1832            Vec::new(),
1833            dir.path(),
1834            std::sync::Arc::new(|_| true),
1835        );
1836        w.providers[0].selected = true;
1837        w.providers[0].value = "sk-ant-x".to_string();
1838        w.providers[0].outcome = crate::commands::setup::verify::Outcome::Failed {
1839            message: "rejected - check the key".into(),
1840        };
1841        w.enter(Step::Review);
1842
1843        let screen = rendered(&w);
1844        assert!(screen.contains("credential set"));
1845        assert!(screen.contains("written out in full"), "secret warning");
1846        assert!(screen.contains("API_TOKEN"));
1847        assert!(screen.contains("Did not verify"));
1848        assert!(
1849            screen.contains("saving anyway is fine"),
1850            "a failed check must not read as a blocker"
1851        );
1852    }
1853
1854    #[test]
1855    fn the_review_screen_says_when_nothing_would_change() {
1856        let (_dir, mut w) = wizard();
1857        for row in w.agents.iter_mut() {
1858            row.selected = false;
1859        }
1860        w.enter(Step::Review);
1861
1862        assert!(rendered(&w).contains("Nothing would change"));
1863    }
1864
1865    #[test]
1866    fn the_footer_shows_a_message_and_the_right_hints_per_screen() {
1867        let (_dir, mut w) = wizard();
1868        w.message = Some("Credentials shown.".to_string());
1869        assert!(rendered(&w).contains("Credentials shown."));
1870
1871        w.message = None;
1872        for (step, expected) in [
1873            (Step::Welcome, "enter begin"),
1874            (Step::Providers, "space/enter select"),
1875            (Step::ProviderDetail, "enter edit"),
1876            (Step::Defaults, "enter change"),
1877            (Step::Limits, "enter change"),
1878            (Step::Agents, "space/enter select"),
1879            (Step::Mcp, "space/enter select"),
1880            (Step::Review, "enter apply"),
1881        ] {
1882            w.enter(step);
1883            let screen = rendered(&w);
1884            assert!(
1885                screen.contains(expected),
1886                "{step:?} footer missing {expected:?}:\n{screen}"
1887            );
1888        }
1889
1890        w.edit = Some(Edit {
1891            target: EditTarget::Field(0),
1892            line: crate::tui::widgets::line_edit::LineEdit::new(String::new(), false),
1893        });
1894        assert!(rendered(&w).contains("esc cancel"));
1895
1896        // A dialog swaps the footer for its own answer hints.
1897        w.edit = None;
1898        w.open_quit_confirm();
1899        assert!(rendered(&w).contains("enter confirm"));
1900    }
1901
1902    #[test]
1903    fn the_help_overlay_lists_the_bindings() {
1904        let (_dir, mut w) = wizard();
1905        w.show_help = true;
1906
1907        let screen = rendered(&w);
1908        assert!(screen.contains("Help"));
1909        assert!(screen.contains("ctrl-s"));
1910        assert!(screen.contains("ctrl-r"));
1911    }
1912
1913    #[test]
1914    fn the_breadcrumb_marks_done_current_and_upcoming_steps() {
1915        let (_dir, mut w) = wizard();
1916        w.enter(Step::Agents);
1917
1918        let screen = rendered(&w);
1919        for step in Step::ALL {
1920            assert!(
1921                screen.contains(step.title()),
1922                "{step:?} missing from header"
1923            );
1924        }
1925    }
1926
1927    #[test]
1928    fn a_choice_field_with_an_out_of_range_index_draws_a_placeholder() {
1929        let (_dir, mut w) = wizard();
1930        w.enter(Step::Defaults);
1931        w.defaults[0].value = FieldValue::Choice {
1932            options: vec!["a".to_string()],
1933            index: 9,
1934        };
1935
1936        assert!(rendered(&w).contains("(none)"));
1937    }
1938}