use anyhow::Result;
use vtcode_core::config::constants::ui;
use vtcode_core::config::models::{ModelId, Provider};
use vtcode_core::config::types::ReasoningEffortLevel;
use vtcode_core::config::validation::effective_model_context_window;
use vtcode_core::ui::{InlineListItem, InlineListSearchConfig, InlineListSelection};
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use super::dynamic_models::DynamicModelRegistry;
use super::options::{ModelOption, option_indexes_for_provider, picker_provider_order};
use super::selection::SelectionDetail;
mod prompts;
pub(super) use prompts::{
prompt_api_key_plain, prompt_custom_model_entry, prompt_reasoning_plain,
prompt_service_tier_plain, render_reasoning_inline, render_service_tier_inline,
show_secure_api_modal,
};
pub(super) const CLOSE_THEME_MESSAGE: &str =
"Close the active model picker before selecting a theme.";
const STEP_ONE_TITLE: &str = "Model";
const STEP_TWO_TITLE: &str = "Reasoning";
const STEP_THREE_TITLE: &str = "Service Tier";
pub(super) const CUSTOM_PROVIDER_TITLE: &str = "Custom provider + model";
pub(super) const CUSTOM_PROVIDER_SUBTITLE: &str =
"Provide the provider name and model identifier manually.";
const CUSTOM_PROVIDER_BADGE: &str = "Manual";
const REASONING_OFF_BADGE: &str = "No reasoning";
const CURRENT_BADGE: &str = "Current";
const CONTEXT_LABEL: &str = "Context";
const TOOLS_LABEL: &str = "Tools";
const NO_TOOLS_LABEL: &str = "No tools";
const CODEX_RUNTIME_NOTE: &str =
"Codex app-server runtime is configured via /config codex, not the /model provider list.";
pub(super) const KEEP_CURRENT_DESCRIPTION: &str = "Retain the existing reasoning configuration.";
pub(super) fn model_search_value(
provider: Provider,
model_display: &str,
model_id: &str,
description: Option<&str>,
extra_terms: &[String],
) -> String {
let provider_label = provider.label();
let provider_key = provider.to_string();
let provider_model_name = format!("{provider_key} {model_display}");
let provider_model_id = format!("{provider_key}/{model_id}");
let mut value = format!(
"{} {} {} {} {} {}",
provider_label,
provider_key,
model_display,
model_id,
provider_model_name,
provider_model_id
);
if let Some(description_text) = description {
value.push(' ');
value.push_str(description_text);
}
for term in extra_terms {
if !term.trim().is_empty() {
value.push(' ');
value.push_str(term);
}
}
value
}
fn is_current_model(
provider: Provider,
model_id: &str,
current_provider: &str,
current_model: &str,
) -> bool {
provider
.to_string()
.eq_ignore_ascii_case(current_provider.trim())
&& model_id.eq_ignore_ascii_case(current_model.trim())
}
fn input_modalities_label(input_modalities: &[&str]) -> Option<String> {
if input_modalities.is_empty() {
return None;
}
Some(format!("Input: {}", input_modalities.join(", ")))
}
fn compact_context_window_label(context_window_size: usize) -> String {
if context_window_size >= 1_000_000 {
format!("{}M", context_window_size / 1_000_000)
} else if context_window_size >= 1_000 {
format!("{}K", context_window_size / 1_000)
} else {
context_window_size.to_string()
}
}
fn context_window_segment(provider: &str, model_id: &str) -> Option<String> {
effective_model_context_window(provider, model_id)
.ok()
.flatten()
.filter(|context_window_size| *context_window_size > 0)
.map(|context_window_size| {
format!(
"{}: {}",
CONTEXT_LABEL,
compact_context_window_label(context_window_size)
)
})
}
fn static_model_capability_segments(option: &ModelOption) -> Vec<String> {
let mut segments = Vec::new();
let provider_key = option.provider.to_string();
if let Some(context_window) = context_window_segment(&provider_key, option.id) {
segments.push(context_window);
}
if option.supports_reasoning {
segments.push("Reasoning".to_string());
}
segments.push(if option.model.supports_tool_calls() {
TOOLS_LABEL.to_string()
} else {
NO_TOOLS_LABEL.to_string()
});
if let Some(modalities) = input_modalities_label(option.model.input_modalities()) {
segments.push(modalities);
}
segments
}
pub(super) fn static_model_search_terms(model: ModelId, supports_reasoning: bool) -> Vec<String> {
let mut terms = Vec::new();
if supports_reasoning {
terms.push("reasoning".to_string());
}
if model.supports_tool_calls() {
terms.push("tools".to_string());
terms.push("tool_call".to_string());
terms.push("toolcall".to_string());
terms.push("tool calling".to_string());
} else {
terms.push("no tools".to_string());
terms.push("no-tools".to_string());
terms.push("tool_call disabled".to_string());
}
let modalities = model.input_modalities();
if !modalities.is_empty() {
terms.push(format!("input {}", modalities.join(" ")));
terms.push("modalities".to_string());
terms.extend(modalities.iter().map(|modality| (*modality).to_string()));
}
terms
}
fn subtitle_from_segments(model_id: &str, current: bool, segments: Vec<String>) -> String {
let mut subtitle = vec![model_id.to_string()];
if current {
subtitle.push(CURRENT_BADGE.to_string());
}
subtitle.extend(segments);
subtitle.join(" • ")
}
pub(super) fn static_model_subtitle(
option: &ModelOption,
current_provider: &str,
current_model: &str,
) -> String {
subtitle_from_segments(
option.id,
is_current_model(option.provider, option.id, current_provider, current_model),
static_model_capability_segments(option),
)
}
pub(super) fn dynamic_model_subtitle(
provider: Provider,
model_id: &str,
reasoning_supported: bool,
current_provider: &str,
current_model: &str,
) -> String {
let mut segments = Vec::new();
let provider_key = provider.to_string();
if let Some(context_window) = context_window_segment(&provider_key, model_id) {
segments.push(context_window);
}
if provider.is_local() {
segments.push("Local".to_string());
}
if reasoning_supported {
segments.push("Reasoning".to_string());
}
subtitle_from_segments(
model_id,
is_current_model(provider, model_id, current_provider, current_model),
segments,
)
}
fn is_current_custom_provider(
provider_key: &str,
model_id: &str,
current_provider: &str,
current_model: &str,
) -> bool {
provider_key
.trim()
.eq_ignore_ascii_case(current_provider.trim())
&& model_id.eq_ignore_ascii_case(current_model.trim())
}
fn custom_provider_subtitle(
selection: &SelectionDetail,
current_provider: &str,
current_model: &str,
) -> String {
let mut segments = Vec::new();
if let Some(context_window) = context_window_segment("openai", &selection.model_id) {
segments.push(context_window);
}
if selection.reasoning_supported {
segments.push("Reasoning".to_string());
}
subtitle_from_segments(
&selection.model_id,
is_current_custom_provider(
&selection.provider_key,
&selection.model_id,
current_provider,
current_model,
),
segments,
)
}
fn custom_provider_search_value(selection: &SelectionDetail) -> String {
let mut value = format!(
"{} {} {} {} custom provider openai compatible",
selection.provider_label,
selection.provider_key,
selection.model_display,
selection.model_id
);
if !selection.env_key.trim().is_empty() {
value.push(' ');
value.push_str(&selection.env_key);
}
value
}
pub(super) fn current_model_line(current_provider: &str, current_model: &str) -> String {
if current_provider.trim().is_empty() || current_model.trim().is_empty() {
return "Pick a model provider and model id.".to_string();
}
let base = format!("Current: {} / {}", current_provider, current_model);
if let Some(context_window) = context_window_segment(current_provider, current_model) {
format!("{base} • {context_window}")
} else {
base
}
}
fn should_show_codex_runtime_note(current_provider: &str) -> bool {
current_provider.trim().eq_ignore_ascii_case("codex")
}
pub(super) fn step_one_header_lines(current_provider: &str, current_model: &str) -> Vec<String> {
let mut lines = vec![
current_model_line(current_provider, current_model),
"↑/↓ select • Enter choose • Esc cancel".to_string(),
];
if should_show_codex_runtime_note(current_provider) {
lines.push(CODEX_RUNTIME_NOTE.to_string());
}
lines
}
pub(super) fn render_step_one_inline(
renderer: &mut AnsiRenderer,
options: &[ModelOption],
_current_reasoning: ReasoningEffortLevel,
dynamic_models: &DynamicModelRegistry,
selected: Option<InlineListSelection>,
current_provider: &str,
current_model: &str,
custom_providers: &[SelectionDetail],
) -> Result<()> {
let mut items = Vec::new();
for provider in picker_provider_order() {
let provider_model_indexes = option_indexes_for_provider(provider);
let dynamic_indexes = dynamic_models.indexes_for(provider);
let has_error = dynamic_models.error_for(provider).is_some();
let has_warning = dynamic_models.warning_for(provider).is_some();
if provider_model_indexes.is_empty()
&& dynamic_indexes.is_empty()
&& !has_error
&& !has_warning
{
continue;
}
for idx in provider_model_indexes {
let Some(option) = options.get(*idx) else {
continue;
};
items.push(InlineListItem {
title: option.display.to_string(),
subtitle: Some(static_model_subtitle(
option,
current_provider,
current_model,
)),
badge: Some(provider.label().to_string()),
indent: 0,
selection: Some(InlineListSelection::Model(*idx)),
search_value: Some(model_search_value(
provider,
option.display,
option.id,
Some(option.description),
&static_model_search_terms(option.model, option.supports_reasoning),
)),
});
}
if provider.is_dynamic() {
for entry_index in dynamic_indexes {
if let Some(detail) = dynamic_models.detail(*entry_index) {
let extra_terms = {
let mut terms = Vec::new();
if provider.is_local() {
terms.push("local".to_string());
}
if detail.reasoning_supported {
terms.push("reasoning".to_string());
}
terms
};
items.push(InlineListItem {
title: detail.model_display.clone(),
subtitle: Some(dynamic_model_subtitle(
provider,
&detail.model_id,
detail.reasoning_supported,
current_provider,
current_model,
)),
badge: Some(provider.label().to_string()),
indent: 0,
selection: Some(InlineListSelection::DynamicModel(*entry_index)),
search_value: Some(model_search_value(
provider,
&detail.model_display,
&detail.model_id,
None,
&extra_terms,
)),
});
}
}
if let Some(warning) = dynamic_models.warning_for(provider) {
items.push(InlineListItem {
title: format!("{} cache notice", provider.label()),
subtitle: Some(warning.to_string()),
badge: Some("Action".to_string()),
indent: 0,
selection: Some(InlineListSelection::RefreshDynamicModels),
search_value: Some(format!("{} cache", provider.label())),
});
}
if dynamic_indexes.is_empty()
&& let Some(error) = dynamic_models.error_for(provider)
{
items.push(InlineListItem {
title: format!("{} unavailable", provider.label()),
subtitle: Some(error.to_string()),
badge: Some("Action".to_string()),
indent: 0,
selection: Some(InlineListSelection::RefreshDynamicModels),
search_value: Some(format!("{} setup", provider.label().to_ascii_lowercase())),
});
}
} else if provider == Provider::HuggingFace && provider_model_indexes.is_empty() {
items.push(InlineListItem {
title: "Custom Hugging Face model".to_string(),
subtitle: Some(
"Enter any HF model id (e.g., huggingface <org>/<model>)".to_string(),
),
badge: Some("Custom".to_string()),
indent: 0,
selection: Some(InlineListSelection::CustomModel),
search_value: Some("huggingface custom".to_string()),
});
}
}
if !custom_providers.is_empty() {
for (index, selection) in custom_providers.iter().enumerate() {
items.push(InlineListItem {
title: selection.provider_label.clone(),
subtitle: Some(custom_provider_subtitle(
selection,
current_provider,
current_model,
)),
badge: Some("Custom".to_string()),
indent: 0,
selection: Some(InlineListSelection::CustomProvider(index)),
search_value: Some(custom_provider_search_value(selection)),
});
}
}
items.push(InlineListItem {
title: "Refresh dynamic model lists".to_string(),
subtitle: Some(
"Re-query GitHub Copilot, LM Studio, and Ollama model lists without closing the picker.".to_string(),
),
badge: Some("Action".to_string()),
indent: 0,
selection: Some(InlineListSelection::RefreshDynamicModels),
search_value: Some("refresh dynamic models".to_string()),
});
items.push(InlineListItem {
title: CUSTOM_PROVIDER_TITLE.to_string(),
subtitle: Some(CUSTOM_PROVIDER_SUBTITLE.to_string()),
badge: Some(CUSTOM_PROVIDER_BADGE.to_string()),
indent: 0,
selection: Some(InlineListSelection::CustomModel),
search_value: Some("custom provider".to_string()),
});
let lines = step_one_header_lines(current_provider, current_model);
let search = InlineListSearchConfig {
label: "Search models".to_string(),
placeholder: Some("provider, name, id, or capability".to_string()),
};
renderer.show_list_modal(STEP_ONE_TITLE, lines, items, selected, Some(search));
Ok(())
}
pub(super) fn render_step_one_plain(
renderer: &mut AnsiRenderer,
options: &[ModelOption],
dynamic_models: &DynamicModelRegistry,
custom_providers: &[SelectionDetail],
current_provider: &str,
) -> Result<()> {
renderer.line(
MessageStyle::Info,
"Model picker: select the model you want to use.",
)?;
renderer.line(
MessageStyle::Info,
"Type '<provider> <model-id>' to select a model.",
)?;
renderer.line(
MessageStyle::Info,
"Type 'cancel' to exit the picker at any time.",
)?;
renderer.line(
MessageStyle::Info,
"Type 'refresh' to re-query LM Studio and Ollama servers.",
)?;
if should_show_codex_runtime_note(current_provider) {
renderer.line(MessageStyle::Info, CODEX_RUNTIME_NOTE)?;
}
let mut first_section = true;
for provider in picker_provider_order() {
let provider_model_indexes = option_indexes_for_provider(provider);
if provider.is_local() {
if !first_section {
renderer.line(MessageStyle::Info, &provider_group_divider_line())?;
}
first_section = false;
renderer.line(MessageStyle::Info, &format!("[{}]", provider.label()))?;
for option_index in provider_model_indexes {
let Some(option) = options.get(*option_index) else {
continue;
};
renderer.line(MessageStyle::Info, &format!(" {}", option.display))?;
renderer.line(
MessageStyle::Info,
&format!(" {}", static_model_subtitle(option, "", "")),
)?;
renderer.line(MessageStyle::Info, &format!(" {}", option.description))?;
}
if let Some(warning) = dynamic_models.warning_for(provider) {
renderer.line(MessageStyle::Info, &format!(" note: {}", warning))?;
}
let dynamic_indexes = dynamic_models.indexes_for(provider);
let provider_label = provider.label();
if dynamic_indexes.is_empty() {
if let Some(error) = dynamic_models.error_for(provider) {
renderer.line(
MessageStyle::Info,
&format!(
"{} server not reachable ({error}) • Setup instructions:",
provider_label
),
)?;
for line in provider.local_install_instructions().unwrap_or("").lines() {
renderer.line(MessageStyle::Info, &format!(" {}", line))?;
}
}
} else {
for entry_index in dynamic_indexes {
if let Some(detail) = dynamic_models.detail(*entry_index) {
renderer
.line(MessageStyle::Info, &format!(" {}", detail.model_display))?;
renderer.line(
MessageStyle::Info,
&format!(
" {}",
dynamic_model_subtitle(
provider,
&detail.model_id,
detail.reasoning_supported,
"",
"",
)
),
)?;
renderer.line(
MessageStyle::Info,
&format!(" Locally available {} model", provider_label),
)?;
}
}
}
} else if provider == Provider::HuggingFace {
if !first_section {
renderer.line(MessageStyle::Info, &provider_group_divider_line())?;
}
first_section = false;
renderer.line(MessageStyle::Info, &format!("[{}]", provider.label()))?;
renderer.line(
MessageStyle::Info,
" Docs: https://huggingface.co/docs/inference-providers",
)?;
for option_index in provider_model_indexes {
let Some(option) = options.get(*option_index) else {
continue;
};
renderer.line(MessageStyle::Info, &format!(" {}", option.display))?;
renderer.line(
MessageStyle::Info,
&format!(" {}", static_model_subtitle(option, "", "")),
)?;
renderer.line(MessageStyle::Info, &format!(" {}", option.description))?;
}
} else {
if provider_model_indexes.is_empty() {
continue;
}
if !first_section {
renderer.line(MessageStyle::Info, &provider_group_divider_line())?;
}
first_section = false;
renderer.line(MessageStyle::Info, &format!("[{}]", provider.label()))?;
for option_index in provider_model_indexes {
let Some(option) = options.get(*option_index) else {
continue;
};
renderer.line(MessageStyle::Info, &format!(" {}", option.display))?;
renderer.line(
MessageStyle::Info,
&format!(" {}", static_model_subtitle(option, "", "")),
)?;
renderer.line(MessageStyle::Info, &format!(" {}", option.description))?;
}
}
}
if !custom_providers.is_empty() {
if !first_section {
renderer.line(MessageStyle::Info, &provider_group_divider_line())?;
}
renderer.line(MessageStyle::Info, "[Custom providers]")?;
for selection in custom_providers {
renderer.line(
MessageStyle::Info,
&format!(" {}", selection.provider_label),
)?;
renderer.line(
MessageStyle::Info,
&format!(" {}", custom_provider_subtitle(selection, "", "")),
)?;
renderer.line(
MessageStyle::Info,
&format!(" env: {}", selection.env_key),
)?;
}
}
Ok(())
}
fn provider_group_divider_line() -> String {
let modal_width = usize::from(ui::MODAL_MIN_WIDTH);
let title_width = STEP_ONE_TITLE.chars().count();
let divider_width = modal_width.max(title_width);
ui::INLINE_USER_MESSAGE_DIVIDER_SYMBOL.repeat(divider_width)
}