use super::{
CONFIRMATION_MODAL, CONNECT_PROVIDER_MODAL, PICKER_MODAL, draw_modal_panel, modal_text_area,
picker_modal_list_area,
};
use crate::tui::{
PADDED_INLINE_SEPARATOR,
single_line_field::SingleLineField,
state::{ConnectProviderField, ConnectProviderStage, MissionControlState},
};
use ratatui::{
Frame,
layout::{Rect, Size},
text::{Line, Span},
widgets::{List, ListItem, ListState, Paragraph, Wrap},
};
use tui_scrollview::ScrollView;
pub(crate) fn connect_provider_modal_visible(area: Rect) -> bool {
modal_text_area(area, "Connect Provider", CONNECT_PROVIDER_MODAL).is_some()
}
pub(crate) fn connect_provider_custom_field_visible(area: Rect) -> bool {
connect_provider_custom_field_area(area).is_some()
}
fn connect_provider_custom_field_area(area: Rect) -> Option<Rect> {
let text_area = modal_text_area(area, "Connect Provider", CONNECT_PROVIDER_MODAL)?;
if text_area.width == 0 || text_area.height < 3 {
return None;
}
let bottom = text_area.y.saturating_add(text_area.height);
let status_y = bottom.saturating_sub(2);
let fields_y = text_area.y.saturating_add(1);
let available = status_y.saturating_sub(fields_y);
(available > 0).then_some(Rect::new(
text_area.x,
fields_y,
text_area.width,
available.min(3),
))
}
pub(crate) fn connect_provider_oauth_controls_visible(area: Rect) -> bool {
modal_text_area(area, "Connect Provider", CONNECT_PROVIDER_MODAL)
.is_some_and(|text_area| text_area.width > 0 && text_area.height >= 2)
}
pub(crate) fn connect_provider_oauth_fallback_visible(area: Rect) -> bool {
modal_text_area(area, "Connect Provider", CONNECT_PROVIDER_MODAL)
.is_some_and(|text_area| text_area.width > 0 && text_area.height >= 6)
}
const LOGOUT_CONFIRMATION_SPACING_ROWS: u16 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LogoutConfirmationLayout {
target_area: Rect,
action_row_area: Rect,
}
pub(crate) fn logout_confirmation_action_row_area(
area: Rect,
label: &str,
provider_id: &str,
) -> Option<Rect> {
logout_confirmation_layout(area, label, provider_id).map(|layout| layout.action_row_area)
}
fn logout_confirmation_layout(
area: Rect,
label: &str,
provider_id: &str,
) -> Option<LogoutConfirmationLayout> {
let text_area = modal_text_area(area, "Confirm Logout", CONFIRMATION_MODAL)?;
if text_area.width == 0 || text_area.height == 0 {
return None;
}
let target = logout_confirmation_target_paragraph(label, provider_id);
let target_rows = target.line_count(text_area.width);
let required_rows = target_rows
.saturating_add(usize::from(LOGOUT_CONFIRMATION_SPACING_ROWS))
.saturating_add(1);
if required_rows > usize::from(text_area.height) {
return None;
}
let target_height = target_rows as u16;
let action_y = text_area
.y
.saturating_add(target_height)
.saturating_add(LOGOUT_CONFIRMATION_SPACING_ROWS);
Some(LogoutConfirmationLayout {
target_area: Rect::new(text_area.x, text_area.y, text_area.width, target_height),
action_row_area: Rect::new(text_area.x, action_y, text_area.width, 1),
})
}
fn logout_confirmation_target_paragraph(label: &str, provider_id: &str) -> Paragraph<'static> {
Paragraph::new(Line::from(Span::raw(format!(
"Remove local auth for {label} ({provider_id})?"
))))
.wrap(Wrap { trim: false })
}
pub(crate) fn connect_provider_choice_list_area(area: Rect) -> Option<Rect> {
let text_area = modal_text_area(area, "Connect Provider", CONNECT_PROVIDER_MODAL)?;
if text_area.height <= 2 {
return None;
}
let list_y = text_area.y.saturating_add(2);
let list_height = text_area
.y
.saturating_add(text_area.height)
.saturating_sub(list_y);
(list_height > 0).then_some(Rect::new(text_area.x, list_y, text_area.width, list_height))
}
pub(in crate::tui::render) fn draw_connect_provider(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
) {
let Some(connect) = &state.modals.connect_provider else {
return;
};
let theme = state.theme;
let Some(text_area) = draw_modal_panel(
frame,
area,
"Connect Provider",
CONNECT_PROVIDER_MODAL,
theme,
) else {
return;
};
if text_area.height == 0 {
return;
}
match connect.stage {
ConnectProviderStage::ChooseProvider => {
draw_connect_provider_choice(frame, text_area, connect, state)
}
ConnectProviderStage::ConfigureCustom => {
draw_connect_provider_custom(frame, text_area, connect, theme)
}
ConnectProviderStage::AnthropicGuidance => {
draw_connect_provider_guidance(frame, text_area, connect, theme)
}
ConnectProviderStage::OAuth => {
draw_connect_provider_oauth(frame, text_area, connect, theme)
}
ConnectProviderStage::ReplacementConfirmation => {
draw_connect_provider_replacement(frame, text_area, connect, theme)
}
ConnectProviderStage::Saving => draw_connect_provider_message(
frame,
text_area,
"Saving provider configuration…",
"Saving is in progress. Please wait while provider metadata is written outside the UI thread.",
None,
theme,
),
ConnectProviderStage::Success => draw_connect_provider_message(
frame,
text_area,
"Provider connected",
connect
.success
.as_deref()
.unwrap_or("Provider configuration is ready."),
Some("[Enter] close [Esc] close"),
theme,
),
ConnectProviderStage::Error => draw_connect_provider_message(
frame,
text_area,
"Connection failed",
connect
.error
.as_deref()
.unwrap_or("The provider could not be connected."),
Some("[Enter] retry [Backspace] back [Esc] close"),
theme,
),
}
}
fn draw_connect_provider_choice(
frame: &mut Frame<'_>,
text_area: Rect,
connect: &crate::tui::state::ConnectProviderState,
state: &MissionControlState,
) {
let theme = state.theme;
frame.render_widget(
Paragraph::new(super::super::hotkey_line_with_theme(
theme,
"Choose a provider [Up]/[Down] move [Enter] connect [Esc] cancel",
theme.pane(),
))
.style(theme.pane()),
Rect::new(text_area.x, text_area.y, text_area.width, 1),
);
if text_area.height < 3 {
return;
}
if let Some(notice) = &connect.notice {
frame.render_widget(
Paragraph::new(notice.clone()).style(theme.muted(false)),
Rect::new(
text_area.x,
text_area.y.saturating_add(1),
text_area.width,
1,
),
);
}
let list_y = text_area.y.saturating_add(2);
let list_height = text_area
.y
.saturating_add(text_area.height)
.saturating_sub(list_y);
let list_area = Rect::new(text_area.x, list_y, text_area.width, list_height);
if list_height == 0 || list_area.width == 0 {
return;
}
let list_height = list_area.height;
if list_height == 0 || list_area.width == 0 {
return;
}
if connect.entries.is_empty() {
frame.render_widget(
Paragraph::new("No providers available").style(theme.pane()),
list_area,
);
return;
}
let content_height =
super::super::usize_to_u16_saturating(connect.entries.len().max(usize::from(list_height)));
let content_width = super::super::scroll_view_content_width(list_area);
if content_width == 0 {
return;
}
let mut view = ScrollView::new(Size::new(content_width, content_height));
let items = connect
.entries
.iter()
.map(|entry| {
ListItem::new(Line::from(vec![Span::raw(format!(
"{} ({}) [{}]{PADDED_INLINE_SEPARATOR}{}",
entry.label, entry.id, entry.status, entry.description
))]))
})
.collect::<Vec<_>>();
let mut list_state = ListState::default().with_selected(Some(connect.selected));
view.render_stateful_widget(
List::new(items)
.style(theme.pane())
.highlight_style(theme.selection())
.highlight_symbol("> "),
Rect::new(0, 0, content_width, content_height),
&mut list_state,
);
let mut local_scroll_state = *state.scroll_views.connect_provider.borrow();
super::super::render_prepared_scroll_view_with_theme(
frame,
list_area,
view,
&mut local_scroll_state,
None,
theme,
);
}
fn draw_connect_provider_custom(
frame: &mut Frame<'_>,
text_area: Rect,
connect: &crate::tui::state::ConnectProviderState,
theme: crate::tui::theme::MissionControlTheme,
) {
let api_key_env_var_is_secret =
crate::config::looks_like_secret_value(connect.api_key_env_var.value());
let displayed_api_key_env_var = if api_key_env_var_is_secret {
let mut masked = SingleLineField::redacted();
masked.set_value(connect.api_key_env_var.value());
masked
} else {
connect.api_key_env_var.clone()
};
let bottom = text_area.y.saturating_add(text_area.height);
frame.render_widget(
Paragraph::new("Configure custom provider metadata (no API key value is requested).")
.style(theme.pane()),
Rect::new(text_area.x, text_area.y, text_area.width, 1),
);
if text_area.height < 2 {
return;
}
let footer_y = bottom.saturating_sub(1);
frame.render_widget(
Paragraph::new(super::super::hotkey_line_with_theme(
theme,
"[Tab] next field [Shift-Tab] previous [Enter] save [Esc] cancel",
theme.pane(),
))
.style(theme.pane()),
Rect::new(text_area.x, footer_y, text_area.width, 1),
);
if text_area.height < 3 {
return;
}
let status_y = bottom.saturating_sub(2);
let (status, status_is_error) = if api_key_env_var_is_secret {
(
"Error: API key environment variable name looks like a secret value; enter a variable name such as CUSTOM_PROVIDER_API_KEY".to_string(),
true,
)
} else if let Some(error) = connect.error.as_deref() {
(
format!("Error: {}", crate::output::redact_sensitive_text(error)),
true,
)
} else if let Some(id) = connect.provider_id.as_deref() {
(format!("Provider ID: {id}"), false)
} else {
("Provider ID: derived from the label".to_string(), false)
};
frame.render_widget(
Paragraph::new(status).style(if status_is_error {
theme.transcript_error_body()
} else {
theme.pane()
}),
Rect::new(text_area.x, status_y, text_area.width, 1),
);
let fields_y = text_area.y.saturating_add(1);
let available = status_y.saturating_sub(fields_y);
if available == 0 || text_area.width == 0 {
return;
}
let fields = [
(
ConnectProviderField::Label,
&connect.label,
"Provider label",
"e.g. Local Llama",
),
(
ConnectProviderField::BaseUrl,
&connect.base_url,
"Base URL",
"https://provider.example/v1",
),
(
ConnectProviderField::ApiKeyEnvVar,
&displayed_api_key_env_var,
"API key environment variable",
"optional, e.g. PROVIDER_API_KEY",
),
];
if available >= 9 {
for (index, (field_kind, field, label, placeholder)) in fields.iter().enumerate() {
field.render(
frame,
Rect::new(
text_area.x,
fields_y.saturating_add((index as u16).saturating_mul(3)),
text_area.width,
3,
),
label,
placeholder,
connect.selected_field == *field_kind,
theme,
);
}
} else {
let (field_kind, field, label, placeholder) = fields
.iter()
.find(|(field_kind, _, _, _)| *field_kind == connect.selected_field)
.unwrap_or(&fields[0]);
field.render(
frame,
Rect::new(text_area.x, fields_y, text_area.width, available.min(3)),
label,
placeholder,
connect.selected_field == *field_kind,
theme,
);
}
}
fn draw_connect_provider_guidance(
frame: &mut Frame<'_>,
text_area: Rect,
connect: &crate::tui::state::ConnectProviderState,
theme: crate::tui::theme::MissionControlTheme,
) {
let lines = vec![
Line::from(Span::raw("Anthropic uses an API key from the environment.")),
Line::from(Span::raw(
"The key is never requested or stored by this modal.",
)),
Line::from(Span::raw(format!(
"Set/export the key, then reopen /login: {}",
connect.instructions
))),
Line::from(Span::raw("")),
super::super::hotkey_line_with_theme(
theme,
"[Enter] back to providers [Esc] close",
theme.pane(),
),
];
frame.render_widget(
Paragraph::new(lines)
.style(theme.pane())
.wrap(Wrap { trim: false }),
text_area,
);
}
fn draw_connect_provider_oauth(
frame: &mut Frame<'_>,
text_area: Rect,
connect: &crate::tui::state::ConnectProviderState,
theme: crate::tui::theme::MissionControlTheme,
) {
let bottom = text_area.y.saturating_add(text_area.height);
if text_area.height < 2 {
frame.render_widget(
Paragraph::new("Starting OAuth login…").style(theme.pane()),
text_area,
);
return;
}
let footer_y = bottom.saturating_sub(1);
frame.render_widget(
Paragraph::new(super::super::hotkey_line_with_theme(
theme,
"[Ctrl-Y] copy URL [Ctrl-O] open browser [Esc] cancel",
theme.pane(),
))
.style(theme.pane()),
Rect::new(text_area.x, footer_y, text_area.width, 1),
);
if text_area.height < 6 {
let lines = if connect.instructions.is_empty() {
vec![Line::from(Span::raw("Waiting for OAuth instructions…"))]
} else {
connect
.instructions
.lines()
.map(|line| Line::from(Span::raw(line.to_string())))
.collect()
};
frame.render_widget(
Paragraph::new(lines)
.style(theme.pane())
.wrap(Wrap { trim: false }),
Rect::new(
text_area.x,
text_area.y,
text_area.width,
footer_y.saturating_sub(text_area.y),
),
);
return;
}
let field_y = bottom.saturating_sub(4);
let notice_y = field_y.saturating_sub(1);
let mut lines = Vec::new();
if connect.url.is_empty() {
lines.extend(
connect
.instructions
.lines()
.map(|line| Line::from(Span::raw(line.to_string()))),
);
} else {
lines.extend([
Line::from(Span::raw("Open the URL in your browser, or use [Ctrl-O].")),
Line::from(Span::raw(
"If needed, paste the redirect URL or authorization code below.",
)),
]);
lines.extend(wrapped_prefixed_lines(
"URL: ",
&connect.url,
text_area.width.max(1),
));
}
frame.render_widget(
Paragraph::new(lines)
.style(theme.pane())
.wrap(Wrap { trim: false }),
Rect::new(
text_area.x,
text_area.y,
text_area.width,
notice_y.saturating_sub(text_area.y),
),
);
frame.render_widget(
Paragraph::new("Manual fallback is transient and redacted.").style(theme.pane()),
Rect::new(text_area.x, notice_y, text_area.width, 1),
);
connect.fallback_input.render(
frame,
Rect::new(text_area.x, field_y, text_area.width, 3),
"OAuth redirect or code",
"paste redirect URL or authorization code",
true,
theme,
);
}
fn draw_connect_provider_replacement(
frame: &mut Frame<'_>,
text_area: Rect,
connect: &crate::tui::state::ConnectProviderState,
theme: crate::tui::theme::MissionControlTheme,
) {
let Some(replacement) = &connect.replacement else {
return;
};
let lines = vec![
Line::from(Span::raw(format!(
"Replace {} ({}) with this metadata?",
replacement.existing_label, replacement.provider_id
))),
Line::from(Span::raw(format!("New label: {}", replacement.label))),
Line::from(Span::raw(format!("New base URL: {}", replacement.base_url))),
Line::from(Span::raw(format!(
"API key env var: {}",
replacement
.api_key_env_var
.as_deref()
.map(safe_api_key_env_var_display)
.unwrap_or("not required")
))),
Line::from(Span::raw("")),
super::super::hotkey_line_with_theme(
theme,
"[Enter] replace [Esc] back to form",
theme.pane(),
),
];
frame.render_widget(
Paragraph::new(lines)
.style(theme.pane())
.wrap(Wrap { trim: false }),
text_area,
);
}
fn draw_connect_provider_message(
frame: &mut Frame<'_>,
text_area: Rect,
heading: &str,
message: &str,
footer: Option<&str>,
theme: crate::tui::theme::MissionControlTheme,
) {
if text_area.height == 0 {
return;
}
let bottom = text_area.y.saturating_add(text_area.height);
let footer_y = bottom.saturating_sub(1);
let has_footer = footer.is_some() && text_area.height >= 2;
if has_footer {
frame.render_widget(
Paragraph::new(super::super::hotkey_line_with_theme(
theme,
footer.unwrap_or_default(),
theme.pane(),
))
.style(theme.pane()),
Rect::new(text_area.x, footer_y, text_area.width, 1),
);
}
let body_height = if has_footer {
footer_y.saturating_sub(text_area.y)
} else {
text_area.height
};
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::raw(heading)),
Line::from(Span::raw("")),
Line::from(Span::raw(message)),
])
.style(theme.pane())
.wrap(Wrap { trim: false }),
Rect::new(text_area.x, text_area.y, text_area.width, body_height),
);
}
fn safe_api_key_env_var_display(value: &str) -> &str {
if crate::config::looks_like_secret_value(value) {
"<redacted>"
} else {
value
}
}
pub(crate) fn logout_picker_list_area(area: Rect, has_notice: bool) -> Option<Rect> {
let header_rows = 1 + u16::from(has_notice);
picker_modal_list_area(area, "Logout Providers", header_rows)
}
pub(in crate::tui::render) fn draw_logout_picker(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
) {
let Some(picker) = &state.modals.logout_picker else {
return;
};
let theme = state.theme;
let Some(text_area) = draw_modal_panel(frame, area, "Logout Providers", PICKER_MODAL, theme)
else {
return;
};
if text_area.height == 0 {
return;
}
frame.render_widget(
Paragraph::new(super::super::hotkey_line_with_theme(
theme,
"[Enter] remove local auth [Esc] cancel",
theme.muted(false),
))
.style(theme.muted(false)),
Rect::new(text_area.x, text_area.y, text_area.width, 1),
);
let mut list_y = text_area.y.saturating_add(1);
if let Some(notice) = &picker.notice
&& list_y < text_area.y.saturating_add(text_area.height)
{
frame.render_widget(
Paragraph::new(notice.clone()).style(theme.muted(false)),
Rect::new(text_area.x, list_y, text_area.width, 1),
);
list_y = list_y.saturating_add(1);
}
let list_height = text_area
.y
.saturating_add(text_area.height)
.saturating_sub(list_y);
if list_height == 0 || text_area.width == 0 {
return;
}
let content_height =
super::super::usize_to_u16_saturating(picker.entries.len().max(usize::from(list_height)));
let content_width =
super::super::scroll_view_content_width(Rect::new(0, 0, text_area.width, list_height));
if content_width == 0 {
return;
}
let mut view = ScrollView::new(Size::new(content_width, content_height));
let items = picker
.entries
.iter()
.map(|entry| {
ListItem::new(Line::from(vec![Span::raw(format!(
"{} ({}) [{}]{PADDED_INLINE_SEPARATOR}{}",
entry.label, entry.id, entry.status, entry.description
))]))
})
.collect::<Vec<_>>();
let mut list_state = ListState::default().with_selected(Some(picker.selected));
view.render_stateful_widget(
List::new(items)
.style(theme.pane())
.highlight_style(theme.selection())
.highlight_symbol("> "),
Rect::new(0, 0, content_width, content_height),
&mut list_state,
);
let mut local_scroll_state = *state.scroll_views.logout_picker.borrow();
super::super::render_prepared_scroll_view_with_theme(
frame,
Rect::new(text_area.x, list_y, text_area.width, list_height),
view,
&mut local_scroll_state,
None,
theme,
);
}
pub(in crate::tui::render) fn draw_logout_confirmation(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
) {
let Some(confirmation) = &state.modals.logout_confirmation else {
return;
};
let theme = state.theme;
let Some(text_area) =
draw_modal_panel(frame, area, "Confirm Logout", CONFIRMATION_MODAL, theme)
else {
return;
};
let layout = logout_confirmation_layout(area, &confirmation.label, &confirmation.provider_id);
let target_area = layout.map(|layout| layout.target_area).unwrap_or(text_area);
frame.render_widget(
logout_confirmation_target_paragraph(&confirmation.label, &confirmation.provider_id)
.style(theme.pane()),
target_area,
);
if let Some(layout) = layout {
frame.render_widget(
Paragraph::new(super::super::hotkey_line_with_theme(
theme,
"[Enter] remove local auth [Esc] cancel",
theme.pane(),
))
.style(theme.pane()),
layout.action_row_area,
);
}
}
fn wrapped_prefixed_lines(prefix: &str, text: &str, width: u16) -> Vec<Line<'static>> {
let width = width.max(1) as usize;
let prefix_len = prefix.chars().count();
let first_chunk_len = width.saturating_sub(prefix_len).max(1);
let mut chunks = text.chars();
let mut lines = Vec::new();
let first = chunks.by_ref().take(first_chunk_len).collect::<String>();
lines.push(Line::from(Span::raw(format!("{prefix}{first}"))));
loop {
let chunk = chunks.by_ref().take(width).collect::<String>();
if chunk.is_empty() {
break;
}
lines.push(Line::from(Span::raw(chunk)));
}
lines
}