use super::{
CONFIRMATION_MODAL, CONNECT_PROVIDER_MODAL, PICKER_MODAL, draw_modal_panel_with_hotkeys,
modal_rect, modal_text_area, picker_modal_list_area,
};
use crate::tui::{
PADDED_INLINE_SEPARATOR,
modal_container::modal_tab,
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;
fn connect_provider_shell_text_area(area: Rect) -> Option<Rect> {
let modal = modal_rect(area, CONNECT_PROVIDER_MODAL)?;
Some(modal.inner(ratatui::layout::Margin::new(2, 1)))
}
fn connect_provider_text_area(area: Rect) -> Option<Rect> {
let mut area = connect_provider_shell_text_area(area)?;
area.y = area.y.saturating_add(1);
area.height = area.height.saturating_sub(1);
Some(area)
}
fn connect_provider_tabs(
connect: &crate::tui::state::ConnectProviderState,
theme: crate::tui::theme::MissionControlTheme,
) -> Line<'static> {
use ConnectProviderStage::*;
let (labels, selected): (&[&str], usize) = match connect.stage {
ChooseProvider => (&["Provider", "Setup", "Result"], 0),
ConfigureCustom => (&["Provider", "Details", "Save", "Result"], 1),
ReplacementConfirmation => (&["Provider", "Details", "Replace", "Result"], 2),
Saving => (&["Provider", "Details", "Save", "Result"], 2),
AnthropicGuidance => (&["Provider", "Environment key"], 1),
OAuth => (&["Provider", "Authorize", "Result"], 1),
Success => (&["Provider", "Setup", "Connected"], 2),
Error => (&["Provider", "Setup", "Error"], 2),
};
Line::from(
labels
.iter()
.enumerate()
.map(|(index, label)| modal_tab(label, index == selected, theme))
.collect::<Vec<_>>(),
)
}
fn connect_provider_hints(stage: ConnectProviderStage) -> (&'static str, Option<&'static str>) {
use ConnectProviderStage::*;
match stage {
ChooseProvider => ("[↑/↓] move [Enter] connect", Some("[Esc] close")),
ConfigureCustom => ("[Enter] save [Tab/Shift-Tab] field", Some("[Esc] cancel")),
AnthropicGuidance => ("[Enter] providers", Some("[Esc] close")),
OAuth => (
"[Tab] focus [Enter] activate [Ctrl-Y] copy URL",
Some("[Esc] cancel"),
),
ReplacementConfirmation => ("[Enter] replace", Some("[Esc] back to form")),
Saving => ("", None),
Success => ("[Enter] close", Some("[Esc] close")),
Error => ("[Enter] retry [Backspace] back", Some("[Esc] close")),
}
}
pub(crate) fn connect_provider_modal_visible(area: Rect) -> bool {
connect_provider_text_area(area).is_some_and(|area| area.height > 0 && area.width > 0)
}
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> {
custom_field_area(connect_provider_text_area(area)?)
}
fn custom_field_area(text_area: Rect) -> Option<Rect> {
if text_area.width < 3 || text_area.height < 5 {
return None;
}
Some(Rect::new(text_area.x, text_area.y + 1, text_area.width, 3))
}
pub(crate) fn connect_provider_oauth_controls_visible(area: Rect) -> bool {
connect_provider_oauth_button_areas(area).is_some()
}
pub(crate) fn connect_provider_oauth_fallback_visible(area: Rect) -> bool {
connect_provider_oauth_controls_visible(area)
}
const OAUTH_REDIRECT_HELP: &str =
"copy the url you are redirected back to so you can paste it below";
fn oauth_help_height(width: u16) -> u16 {
Paragraph::new(OAUTH_REDIRECT_HELP)
.wrap(Wrap { trim: false })
.line_count(width.max(1)) as u16
}
fn oauth_button_areas(text_area: Rect) -> Option<[Rect; 2]> {
if text_area.width < 35 || text_area.height < 6 {
return None;
}
let y = text_area.bottom().saturating_sub(4);
Some([
Rect::new(text_area.x, y, 22, 1),
Rect::new(text_area.x + 24, y, 10, 1),
])
}
pub(crate) fn connect_provider_oauth_button_areas(area: Rect) -> Option<[Rect; 2]> {
oauth_button_areas(connect_provider_text_area(area)?)
}
#[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 modal = modal_rect(area, CONFIRMATION_MODAL)?;
if target_rows > usize::from(text_area.height) || modal.width < 28 {
return None;
}
let target_height = target_rows as u16;
let action_y = modal.bottom().saturating_sub(1);
Some(LogoutConfirmationLayout {
target_area: Rect::new(text_area.x, text_area.y, text_area.width, target_height),
action_row_area: Rect::new(modal.x + 1, action_y, modal.width.saturating_sub(2), 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 = connect_provider_text_area(area)?;
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 (hotkeys, close_hint) = connect_provider_hints(connect.stage);
let Some(_) = draw_modal_panel_with_hotkeys(
frame,
area,
"Connect Provider",
CONNECT_PROVIDER_MODAL,
theme,
hotkeys,
close_hint,
) else {
return;
};
let Some(shell_text_area) = connect_provider_shell_text_area(area) else {
return;
};
frame.render_widget(
Paragraph::new(connect_provider_tabs(connect, theme)),
Rect::new(
shell_text_area.x,
shell_text_area.y,
shell_text_area.width,
shell_text_area.height.min(1),
),
);
let Some(text_area) = connect_provider_text_area(area) 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.",
theme,
),
ConnectProviderStage::Success => draw_connect_provider_message(
frame,
text_area,
"Provider connected",
connect
.success
.as_deref()
.unwrap_or("Provider configuration is ready."),
theme,
),
ConnectProviderStage::Error => draw_connect_provider_message(
frame,
text_area,
"Connection failed",
connect
.error
.as_deref()
.unwrap_or("The provider could not be connected."),
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("Choose a provider").style(theme.app()),
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()
};
if custom_field_area(text_area).is_none() {
frame.render_widget(
Paragraph::new("Enlarge the terminal to edit provider details.")
.style(theme.pane())
.wrap(Wrap { trim: false }),
text_area,
);
return;
}
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),
);
let status_y = bottom.saturating_sub(1);
let (status, status_is_error) = if api_key_env_var_is_secret {
(
"Error: looks like a secret value; enter an environment variable name".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",
),
(
ConnectProviderField::ModelsDevProvider,
&connect.models_dev_provider,
"models.dev provider override",
"optional, e.g. openai",
),
];
if available >= 12 {
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
))),
];
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,
) {
use crate::tui::state::OAuthControl;
let Some([open_area, copy_area]) = oauth_button_areas(text_area) else {
frame.render_widget(
Paragraph::new("Enlarge the terminal to show OAuth controls. [Esc] cancels.")
.style(theme.pane())
.wrap(Wrap { trim: false }),
text_area,
);
return;
};
let compact = text_area.height < oauth_help_height(text_area.width) + 7;
let help_height = if compact {
0
} else {
oauth_help_height(text_area.width)
};
let help_y = open_area.y.saturating_sub(help_height);
let url_y = text_area.y + 1;
let url_height = help_y.saturating_sub(url_y).max(1);
let url_lines = if connect.url.is_empty() {
vec![Line::from("Waiting for OAuth instructions…")]
} else {
wrapped_prefixed_lines("URL: ", &connect.url, text_area.width)
};
let page_count = url_lines.len().div_ceil(usize::from(url_height)).max(1);
let page = connect.oauth_url_page.rem_euclid(page_count as i32) as usize;
let heading = if page_count > 1 {
format!("URL {}/{} [PgUp/PgDn] more", page + 1, page_count)
} else {
"Authorize OpenAI Codex".to_string()
};
frame.render_widget(
Paragraph::new(heading).style(theme.pane()),
Rect::new(text_area.x, text_area.y, text_area.width, 1),
);
frame.render_widget(
Paragraph::new(
url_lines
.into_iter()
.skip(page * usize::from(url_height))
.take(usize::from(url_height))
.collect::<Vec<_>>(),
)
.style(theme.pane().fg(theme.text_accent())),
Rect::new(text_area.x, url_y, text_area.width, url_height),
);
if !compact {
frame.render_widget(
Paragraph::new(OAUTH_REDIRECT_HELP)
.style(theme.pane())
.wrap(Wrap { trim: false }),
Rect::new(text_area.x, help_y, text_area.width, help_height),
);
}
for (area, label, control) in [
(
open_area,
"[Open Browser] [Alt-O]",
OAuthControl::OpenBrowser,
),
(copy_area, "[Copy URL]", OAuthControl::CopyUrl),
] {
let style = if connect.url.is_empty() {
theme.muted(false)
} else if connect.oauth_focus == control {
theme
.pane()
.bg(theme.surface_selection())
.fg(theme.text_accent())
} else {
theme.pane().fg(theme.text_accent())
};
frame.render_widget(Paragraph::new(label).style(style), area);
}
connect.fallback_input.render(
frame,
Rect::new(text_area.x, open_area.y + 1, text_area.width, 3),
"OAuth redirect or code (redacted)",
"paste redirect URL or authorization code",
connect.oauth_focus == OAuthControl::Fallback,
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(format!(
"models.dev override: {}",
if replacement.models_dev_provider.is_empty() {
"provider ID (default)"
} else {
&replacement.models_dev_provider
}
)),
];
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,
theme: crate::tui::theme::MissionControlTheme,
) {
if text_area.height == 0 {
return;
}
let body_height = 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_with_hotkeys(
frame,
area,
"Logout Providers",
PICKER_MODAL,
theme,
"[↑/↓] move [Enter] remove local auth",
Some("[Esc] close"),
) else {
return;
};
if text_area.height == 0 {
return;
}
frame.render_widget(
Paragraph::new("Choose a provider").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 layout = logout_confirmation_layout(area, &confirmation.label, &confirmation.provider_id);
let hotkeys = if layout.is_some() {
"[Enter] remove local auth"
} else {
""
};
let Some(text_area) = draw_modal_panel_with_hotkeys(
frame,
area,
"Confirm Logout",
CONFIRMATION_MODAL,
theme,
hotkeys,
Some("[Esc] cancel"),
) else {
return;
};
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,
);
}
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_tabs_follow_branches_and_future_steps_stay_muted() {
let mut state = MissionControlState::default();
state.open_connect_provider(Vec::new(), None);
let theme = state.theme;
let connect = state.modals.connect_provider.as_mut().unwrap();
for (stage, active, future) in [
(
ConnectProviderStage::ChooseProvider,
"[Provider]",
Some("Result"),
),
(
ConnectProviderStage::ConfigureCustom,
"[Details]",
Some("Save"),
),
(ConnectProviderStage::OAuth, "[Authorize]", Some("Result")),
(
ConnectProviderStage::AnthropicGuidance,
"[Environment key]",
None,
),
(
ConnectProviderStage::ReplacementConfirmation,
"[Replace]",
Some("Result"),
),
(ConnectProviderStage::Saving, "[Save]", Some("Result")),
] {
connect.stage = stage;
let tabs = connect_provider_tabs(connect, theme);
assert!(tabs.spans.iter().any(|span| span.content == active));
if let Some(future) = future {
let span = tabs
.spans
.iter()
.find(|span| span.content.trim() == future)
.unwrap();
assert_eq!(span.style, theme.muted(false));
}
if stage == ConnectProviderStage::AnthropicGuidance {
assert_eq!(
tabs.spans.len(),
2,
"environment guidance has no automatic completion step"
);
}
}
assert_eq!(
connect_provider_hints(ConnectProviderStage::Saving),
("", None)
);
assert_eq!(
connect_provider_hints(ConnectProviderStage::ReplacementConfirmation).1,
Some("[Esc] back to form")
);
}
}