use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::symbols::border;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
use crate::profile::DivergenceChoice;
use super::super::app::{
ActionMenuState, App, ConfirmAction, ConfirmState, DivergenceForm, DivergenceTargetForm,
EnvCollisionChoice, EnvCollisionForm, InputState, LoginStage, Modal, Tab,
};
use super::super::theme;
use super::format::spinner_frame;
use super::panes::{bold_when, head_cols};
pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App, modal: &Modal) {
match modal {
Modal::Confirm(state) => draw_confirm(frame, area, state),
Modal::Divergence(form) => draw_divergence(frame, area, form),
Modal::CaptureName(form) => draw_capture_name(frame, area, &form.input),
Modal::DivergenceTarget(form) => draw_divergence_target(frame, area, form),
Modal::Help => draw_help(frame, area, app),
Modal::ActionMenu(state) => draw_action_menu(frame, area, state),
Modal::EnvCollision(form) => draw_env_collision(frame, area, form),
Modal::Login => draw_login_progress(frame, area, app),
}
}
fn draw_login_progress(frame: &mut Frame<'_>, area: Rect, app: &App) {
let Some(session) = app.login.as_ref() else {
return; };
let stage = match session.stage {
LoginStage::WaitingBrowser => "waiting for the browser login",
LoginStage::ExchangingCode => "exchanging the code for tokens",
LoginStage::Verifying => "verifying the minted token",
};
let mut lines: Vec<Line<'_>> = vec![
Line::from(Span::styled(
format!("logging in '{}'", session.name),
theme::body(),
)),
Line::from(""),
Line::from(vec![
Span::styled(
format!("{} ", spinner_frame(app.tick_count)),
theme::accent(),
),
Span::styled(stage, theme::dim()),
]),
Line::from(""),
];
match session.url {
Some(_) => {
lines.push(Line::from(Span::styled(
"complete the login in your browser",
theme::dim(),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("r", theme::accent().add_modifier(Modifier::BOLD)),
Span::styled(" open the browser again", theme::dim()),
]));
}
None => lines.push(Line::from(Span::styled(
"opening your browser…",
theme::dim(),
))),
}
draw_modal(frame, area, "LOGIN", lines);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let w = width.min(area.width.saturating_sub(4));
let h = height.min(area.height.saturating_sub(4));
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
fn draw_modal(frame: &mut Frame<'_>, area: Rect, title: &str, lines: Vec<Line<'_>>) {
let content_w = lines.iter().map(Line::width).max().unwrap_or(0) as u16;
let w = (content_w + 6)
.max(title.chars().count() as u16 + 4)
.min(area.width.saturating_sub(4));
let h = (lines.len() as u16 + 4).min(area.height.saturating_sub(4));
let rect = centered(area, w, h);
frame.render_widget(Clear, rect);
let block = modal_block(title);
let inner = block.inner(rect);
frame.render_widget(block, rect);
frame.render_widget(Paragraph::new(lines).style(theme::base()), inner);
}
fn modal_block(title: impl Into<String>) -> Block<'static> {
let title_line = Line::from(vec![
Span::raw(" "),
Span::styled(
title.into().to_uppercase(),
Style::default()
.fg(theme::text_dim_color())
.add_modifier(Modifier::ITALIC),
),
Span::raw(" "),
]);
Block::default()
.borders(Borders::ALL)
.border_set(border::ROUNDED)
.border_style(Style::default().fg(theme::accent_2_color()))
.title(title_line)
.style(theme::base())
.padding(Padding::new(2, 2, 1, 1))
}
fn draw_confirm(frame: &mut Frame<'_>, area: Rect, state: &ConfirmState) {
let title = match state.on_confirm {
ConfirmAction::CaptureConflict(..) => "CONFIRM",
ConfirmAction::CaptureOverwrite(..) => "CONFIRM",
ConfirmAction::AdoptDivergence(..) => "CONFIRM",
ConfirmAction::Switch(_) => "CONFIRM",
ConfirmAction::DiscardDivergence(_) => "CONFIRM",
ConfirmAction::RotateAll => "CONFIRM",
ConfirmAction::RotateOne(_) => "CONFIRM",
ConfirmAction::WireMcpServers => "CONFIRM",
ConfirmAction::RelinkCredentials(_) => "CONFIRM",
ConfirmAction::BlankCredentials(_) => "CONFIRM",
ConfirmAction::RestartLogin(..) => "CONFIRM",
};
let destructive = matches!(
state.on_confirm,
ConfirmAction::Switch(_)
| ConfirmAction::RotateAll
| ConfirmAction::RotateOne(_)
| ConfirmAction::CaptureOverwrite(..)
| ConfirmAction::AdoptDivergence(..)
| ConfirmAction::BlankCredentials(_)
);
let mut lines: Vec<Line<'_>> = vec![Line::from(Span::styled(
state.message.clone(),
theme::body(),
))];
if let Some(detail) = &state.detail {
lines.push(Line::from(Span::styled(detail.clone(), theme::dim())));
}
lines.push(Line::from(""));
lines.push(choice_buttons(state.choice, destructive).alignment(Alignment::Right));
draw_modal(frame, area, title, lines);
}
fn choice_buttons(choice: bool, destructive_confirm: bool) -> Line<'static> {
Line::from(vec![
modal_button(" cancel ", !choice),
Span::raw(" "),
if destructive_confirm {
danger_button(" confirm ", choice)
} else {
modal_button(" confirm ", choice)
},
])
}
fn modal_button(label: &str, focused: bool) -> Span<'static> {
if focused {
Span::styled(
label.to_string(),
Style::default().fg(theme::bg()).bg(theme::text_color()),
)
} else {
Span::styled(label.to_string(), theme::dim())
}
}
fn danger_button(label: &str, focused: bool) -> Span<'static> {
if focused {
Span::styled(
label.to_string(),
Style::default().fg(theme::bg()).bg(theme::danger_color()),
)
} else {
Span::styled(label.to_string(), theme::danger())
}
}
fn draw_divergence(frame: &mut Frame<'_>, area: Rect, form: &DivergenceForm) {
let options = DivergenceForm::options();
let cursor = form.cursor.min(options.len() - 1);
let mut lines: Vec<Line<'_>> = vec![
Line::from(vec![
Span::styled("the live login no longer matches ", theme::dim()),
Span::styled(
format!("'{}'", form.active),
Style::default().fg(theme::accent_color()),
),
Span::styled(".", theme::dim()),
]),
Line::from(""),
];
for (i, option) in options.iter().enumerate() {
let selected = i == cursor;
lines.push(option_line(
selected,
divergence_option_text(*option, &form.active),
));
}
draw_modal(frame, area, "DIVERGENCE", lines);
}
fn divergence_option_text(option: DivergenceChoice, active: &str) -> String {
match option {
DivergenceChoice::Overwrite => format!("overwrite '{active}' with this login"),
DivergenceChoice::NewProfile => "save this login to another profile…".to_string(),
DivergenceChoice::Discard => format!("discard this login and restore '{active}'"),
}
}
fn option_line(selected: bool, label: String) -> Line<'static> {
let arrow = if selected {
Span::styled("\u{276f} ", theme::accent())
} else {
Span::raw(" ")
};
let style = if selected {
theme::accent()
} else {
theme::dim()
};
Line::from(vec![arrow, Span::styled(label, style)])
}
fn draw_divergence_target(frame: &mut Frame<'_>, area: Rect, form: &DivergenceTargetForm) {
let cursor = form.cursor.min(form.targets.len());
let mut lines: Vec<Line<'_>> = vec![
Line::from(Span::styled("where to save the login?", theme::dim())),
Line::from(""),
option_line(cursor == 0, "+ new profile".to_string()),
];
for (i, name) in form.targets.iter().enumerate() {
lines.push(option_line(cursor == i + 1, format!("overwrite '{name}'")));
}
draw_modal(frame, area, "SAVE LOGIN", lines);
}
fn draw_env_collision(frame: &mut Frame<'_>, area: Rect, form: &EnvCollisionForm) {
let options = EnvCollisionForm::options();
let cursor = form.cursor.min(options.len() - 1);
let mut lines: Vec<Line<'_>> = vec![
Line::from(vec![
Span::styled(
format!("'{}'", form.key),
Style::default().fg(theme::accent_color()),
),
Span::styled(" is already used by ", theme::dim()),
Span::styled(form.reason.clone(), theme::body()),
Span::styled(".", theme::dim()),
]),
Line::from(""),
];
for (i, option) in options.iter().enumerate() {
let selected = i == cursor;
let arrow = if selected {
Span::styled("\u{276f} ", theme::accent())
} else {
Span::raw(" ")
};
let (label, detail) = env_collision_option_text(*option, form);
let label_style = if selected {
theme::accent()
} else {
theme::dim()
};
lines.push(Line::from(vec![arrow, Span::styled(label, label_style)]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(detail, theme::dim()),
]));
}
draw_modal(frame, area, "KEY IN USE", lines);
}
fn env_collision_option_text(
choice: EnvCollisionChoice,
form: &EnvCollisionForm,
) -> (String, String) {
match choice {
EnvCollisionChoice::Overwrite => (
"add the custom field anyway".to_string(),
format!("this account's value overrides {}", form.reason),
),
EnvCollisionChoice::KeepExisting => (
"keep the existing value".to_string(),
if form.existing_idx.is_some() {
"jump to the existing custom field".to_string()
} else {
"leave it untouched; don't add the field".to_string()
},
),
EnvCollisionChoice::Cancel => ("cancel".to_string(), "back out, no change".to_string()),
}
}
fn draw_capture_name(frame: &mut Frame<'_>, area: Rect, input: &InputState) {
let lines = vec![
Line::from(Span::styled(
"stores the live ~/.claude/.credentials.json under this profile.",
theme::dim(),
)),
Line::from(""),
labelled_input("name", input, true),
];
let title = "CAPTURE";
let content_w = lines.iter().map(Line::width).max().unwrap_or(0) as u16;
let w = (content_w + 6)
.max(title.chars().count() as u16 + 4)
.min(area.width.saturating_sub(4));
let h = (lines.len() as u16 + 4).min(area.height.saturating_sub(4));
let rect = {
let cw = w.min(area.width.saturating_sub(4));
let ch = h.min(area.height.saturating_sub(4));
Rect {
x: area.x + (area.width.saturating_sub(cw)) / 2,
y: area.y + (area.height.saturating_sub(ch)) / 2,
width: cw,
height: ch,
}
};
let inner_x = rect.x.saturating_add(3);
let inner_y = rect.y.saturating_add(2);
draw_modal(frame, area, title, lines);
let cx = inner_x.saturating_add(2 + 4 + 1 + head_cols(input) as u16);
let cy = inner_y.saturating_add(2); frame.set_cursor_position((cx, cy));
}
fn tab_specific_rows(tab: Tab) -> Vec<(&'static str, &'static [(&'static str, &'static str)])> {
match tab {
Tab::Overview => vec![(
"accounts",
&[
("\u{2191}\u{2193}", "move cursor"),
("\u{21b5}", "switch to selected account (confirm)"),
("shift \u{2191}\u{2193}", "reorder account up / down"),
][..],
)],
Tab::Usage => vec![(
"usage",
&[
("\u{2191}\u{2193}", "pick account to inspect"),
("r", "refresh account"),
("e", "toggle estimates"),
("p", "toggle pace marker"),
][..],
)],
Tab::Tokens => vec![(
"tokens",
&[
("\u{21b5}", "open per-model breakdown"),
("\u{2191}\u{2193}", "pick model (in breakdown)"),
("c", "count cache in token figures"),
(
"t",
"cycle period \u{b7} lifetime / daily / weekly / monthly",
),
("r", "reload on-disk stats"),
("esc", "back to dashboard"),
][..],
)],
Tab::Setup => vec![(
"setup",
&[
("\u{2191}\u{2193}", "pick account / + new, then a row"),
("\u{21b5}", "open settings · edit field · flip toggle"),
("\u{21b5} on a field", "edit inline; \u{21b5} again saves"),
("space", "cycle the model preset (model row)"),
("env", "+ add env · \u{21b5} edits a value · a removes"),
("delete", "\u{21b5} once to arm, again to confirm"),
("esc", "stop editing / back to account list"),
][..],
)],
Tab::Config => vec![(
"config",
&[
("\u{2191}\u{2193}", "move between settings"),
("space", "cycle the focused setting"),
(
"\u{21b5}",
"same as space · custom value on refresh interval",
),
][..],
)],
Tab::Status => vec![(
"status",
&[
("\u{2191}\u{2193}", "pick incident / scroll detail"),
("\u{21b5}", "open incident timeline"),
("r", "refresh the feed"),
("esc", "back to the list"),
][..],
)],
Tab::Plugin => vec![(
"plugin",
&[
("\u{2191}\u{2193}", "pick check · scroll detail"),
("\u{21b5}", "open the selected row's detail"),
("f", "apply the selected row's fix"),
("r", "re-run all checks"),
("esc", "back to the list"),
][..],
)],
Tab::Fallback => vec![(
"fallback chain",
&[
("\u{2191}\u{2193}", "move cursor / detail row"),
("shift \u{2191}\u{2193}", "reorder member = priority"),
(
"\u{21b5}",
"open \u{00b7} edit threshold \u{00b7} toggle last resort \u{00b7} remove \u{00b7} add",
),
("+ / -", "step threshold by 5"),
("\u{21b5}", "type a threshold, \u{21b5} saves"),
("esc", "back / cancel edit"),
][..],
)],
}
}
fn draw_help(frame: &mut Frame<'_>, area: Rect, app: &App) {
let title = "KEYS";
let tab_specific = tab_specific_rows(app.tab);
let nav: &[(&str, &str)] = &[(
"\u{2190} \u{2192} \u{00b7} tab",
"previous / next tab (shift tab: previous)",
)];
let global_all: &[(&str, &str)] = &[
("n", "new account"),
("r", "refresh usage now"),
("t", "rotate all tokens"),
("?", "toggle this help"),
("a", "actions"),
("x", "dismiss toast / alert"),
("q", "back / quit"),
("esc", "back within a sub-view (no-op at the top level)"),
("\u{2303}c", "quit from anywhere"),
];
let shadowed: Vec<&str> = tab_specific
.iter()
.flat_map(|(_, rows)| rows.iter().map(|(k, _)| *k))
.collect();
let global: Vec<(&str, &str)> = global_all
.iter()
.copied()
.filter(|(k, _)| !shadowed.contains(k))
.collect();
let mut lines: Vec<Line<'_>> = Vec::new();
lines.extend(key_section("tabs", nav));
for (section, entries) in &tab_specific {
lines.extend(key_section(section, entries));
}
lines.extend(key_section("global", &global));
lines.pop(); draw_modal(frame, area, title, lines);
}
fn key_section(title: &str, pairs: &[(&str, &str)]) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(Span::styled(
title.to_uppercase(),
Style::default().fg(theme::text_dim_color()),
)),
Line::from(""),
];
for (key, desc) in pairs {
lines.push(help_row(key, desc));
}
lines.push(Line::from(""));
lines
}
fn help_row(key: &str, desc: &str) -> Line<'static> {
const KEY_W: usize = 18;
let pad = KEY_W.saturating_sub(key.chars().count()).max(1);
Line::from(vec![
Span::styled(
format!(" {key}{}", " ".repeat(pad)),
Style::default().fg(theme::accent_color()).bold(),
),
Span::styled(desc.to_string(), Style::default().fg(theme::text_color())),
])
}
fn labelled_input(label: &str, input: &InputState, focused: bool) -> Line<'static> {
let value_style = if focused {
Style::default()
.fg(theme::text_color())
.bg(theme::bg_sunken())
} else {
Style::default().fg(theme::text_color())
};
let gutter = if focused {
Span::styled(format!("{} ", theme::edit_glyph()), theme::accent())
} else {
Span::raw(" ")
};
Line::from(vec![
gutter,
Span::styled(label.to_string(), theme::label()),
Span::raw(" "),
Span::styled(input.value.clone(), value_style),
])
}
fn draw_action_menu(frame: &mut Frame<'_>, area: Rect, state: &ActionMenuState) {
const HOTKEY_W: u16 = 1; const GUTTER: u16 = 2;
let max_label_w = state
.items
.iter()
.map(|item| item.label.chars().count())
.max()
.unwrap_or(0) as u16;
let content_w = GUTTER + max_label_w + 3 + HOTKEY_W;
let title = "actions";
let w = (content_w + 6)
.max(title.chars().count() as u16 + 4)
.min(area.width.saturating_sub(4));
let h = (state.items.len() as u16 + 4).min(area.height.saturating_sub(4));
let rect = centered(area, w, h);
frame.render_widget(Clear, rect);
let block = modal_block(title);
let inner = block.inner(rect);
frame.render_widget(block, rect);
let inner_w = inner.width;
for (i, item) in state.items.iter().enumerate() {
let focused = i == state.cursor;
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let row_area = Rect {
y,
height: 1,
..inner
};
let label_style = bold_when(Style::default().fg(theme::text_color()), focused);
let row_bg = if focused {
Style::default().bg(theme::bg_hover())
} else {
theme::base()
};
let glyph = if focused {
Span::styled("❯ ", Style::default().fg(theme::accent_color()).bold())
} else {
Span::styled(" ", Style::default())
};
let label_len = item.label.chars().count() as u16;
let pad = inner_w
.saturating_sub(GUTTER)
.saturating_sub(label_len)
.saturating_sub(HOTKEY_W);
let padding = Span::styled(" ".repeat(pad as usize), Style::default());
let hotkey_span = match item.hotkey {
Some(c) => Span::styled(c.to_string(), Style::default().fg(theme::text_dim_color())),
None => Span::styled(
" ".to_string(),
Style::default().fg(theme::text_dim_color()),
),
};
let line = Line::from(vec![
glyph,
Span::styled(item.label.to_string(), label_style),
padding,
hotkey_span,
])
.style(row_bg);
frame.render_widget(Paragraph::new(line).style(row_bg), row_area);
}
}
#[cfg(test)]
#[path = "../../../tests/inline/tui_render_modals.rs"]
mod tests;