1use 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
33const MIN_WIDTH: u16 = 24;
37const MIN_HEIGHT: u16 = 6;
38
39pub 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
75fn 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
88pub fn picker_row_at(area: Rect, picker: &Picker, row: u16) -> Option<usize> {
92 let popup = centered(80, 88, area);
93 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
108fn 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 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
177fn 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
215fn continue_line(wizard: &Wizard) -> Line<'static> {
217 button_line(&wizard.continue_label(), wizard.on_continue())
218}
219
220fn 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#[derive(Default)]
257struct Screen {
258 lines: Vec<Line<'static>>,
259 rows: Vec<usize>,
262}
263
264impl Screen {
265 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 fn finish(mut self, wizard: &Wizard) -> Self {
280 self.blank();
281 self.row();
282 self.push(continue_line(wizard));
283 self
284 }
285
286 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
301fn 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
319pub 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 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
355fn body_layout(area: Rect) -> std::rc::Rc<[Rect]> {
358 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
372fn 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
385fn line_width(line: &Line<'_>) -> usize {
387 line.spans.iter().map(|s| s.content.chars().count()).sum()
388}
389
390fn 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
408fn 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 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 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 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 if blank {
476 break;
477 }
478 continue;
479 }
480 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 if !current.is_empty() {
502 out.push(close_row(&mut current));
503 }
504 out
505}
506
507fn 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 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
536fn draw_screen(frame: &mut Frame, inner: Rect, outer: Rect, screen: &Screen, wizard: &Wizard) {
539 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 return Screen::default().finish(wizard);
659 };
660 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 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 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 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
766fn 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
785fn 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
801fn 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 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
1037fn 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
1109fn 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 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 #[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 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 assert_eq!(wrapped_text(&plain, 40), ["alpha beta gamma"]);
1164 assert_eq!(
1166 wrapped_text(&Line::from("abcdefghij"), 4),
1167 ["abcd", "efgh", "ij"]
1168 );
1169 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 assert_eq!(wrapped_text(&Line::from("ab"), 0), ["a", "b"]);
1176 assert_eq!(wrapped_text(&Line::from("ab cdef"), 4), ["ab", "cdef"]);
1179 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 assert_eq!(wrapped_text(&Line::from("aaaa "), 4), ["aaaa"]);
1189 assert_eq!(wrapped_text(&Line::from("aaaa bb"), 4), ["aaaa", "bb"]);
1190 }
1191
1192 #[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 #[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 assert_eq!(
1214 wrapped_text(&indented, 8),
1215 [" alph", "a beta", "gamma", "delta"]
1216 );
1217 }
1218
1219 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 assert_eq!(picker_row_at(Rect::new(0, 0, 60, 5), picker, 3), None);
1410 assert_eq!(picker_row_at(Rect::new(0, 0, 90, 40), picker, 1), None);
1412 }
1413
1414 #[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 assert_eq!(row_at(area, &w, 4, 34), None);
1425 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 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 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 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 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 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 assert!(rendered(&w).contains("› API key:"));
1714
1715 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 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 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}