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