use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
Frame,
};
use super::app::{Filters, Focus, ProviderListItem, SortOrder};
use crate::formatting::truncate;
use crate::formatting::EM_DASH;
use crate::provider_category::{provider_category, ProviderCategory};
use crate::tui::app::App;
use crate::tui::ui::{caret, focus_border};
use crate::tui::widgets::scrollable_panel::ScrollablePanel;
fn provider_detail_lines(app: &App) -> Vec<Line<'static>> {
let Some(entry) = app.models_app.current_model() else {
return vec![Line::from(Span::styled(
"No model selected",
Style::default().fg(Color::DarkGray),
))];
};
let provider = app
.providers
.iter()
.find(|(id, _)| id == &entry.provider_id)
.map(|(_, p)| p);
let Some(provider) = provider else {
return vec![Line::from(Span::styled(
"Provider not found",
Style::default().fg(Color::DarkGray),
))];
};
let cat = provider_category(&entry.provider_id);
let has_doc = provider.doc.is_some();
let has_api = provider.api.is_some();
let mut lines = vec![
Line::from(vec![Span::styled(
provider.name.clone(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled("Category: ", Style::default().fg(Color::Gray)),
Span::styled(cat.label(), Style::default().fg(cat.color())),
]),
Line::from(vec![
Span::styled("Docs: ", Style::default().fg(Color::Gray)),
Span::raw(provider.doc.clone().unwrap_or_else(|| EM_DASH.into())),
]),
Line::from(vec![
Span::styled("API: ", Style::default().fg(Color::Gray)),
Span::raw(provider.api.clone().unwrap_or_else(|| EM_DASH.into())),
]),
Line::from(vec![
Span::styled("Env: ", Style::default().fg(Color::Gray)),
Span::raw(if provider.env.is_empty() {
EM_DASH.to_string()
} else {
provider.env.join(", ")
}),
]),
];
let mut hints: Vec<Span<'static>> = Vec::new();
if has_doc {
hints.push(Span::styled("o ", Style::default().fg(Color::Yellow)));
hints.push(Span::raw("docs"));
}
if has_doc && has_api {
hints.push(Span::raw(" "));
}
if has_api {
hints.push(Span::styled("A ", Style::default().fg(Color::Yellow)));
hints.push(Span::raw("api"));
}
if !hints.is_empty() {
lines.push(Line::from(hints));
}
lines
}
fn draw_right_panel(f: &mut Frame, area: Rect, app: &App) {
let lines = provider_detail_lines(app);
let border_block = Block::default().borders(Borders::ALL);
let inner_w = border_block.inner(area).width as usize;
let visual_lines: u16 = if inner_w == 0 {
lines.len() as u16
} else {
lines
.iter()
.map(|line| {
let w = line.width();
if w <= inner_w {
1u16
} else {
w.div_ceil(inner_w) as u16 + 1
}
})
.sum()
};
let provider_h = visual_lines + 2;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(provider_h), Constraint::Min(0)])
.split(area);
draw_provider_detail(f, chunks[0], lines);
draw_model_detail(f, chunks[1], app);
}
pub(in crate::tui) fn draw_main(f: &mut Frame, area: Rect, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(45),
Constraint::Percentage(35),
])
.split(area);
draw_providers(f, chunks[0], app);
draw_models(f, chunks[1], app);
draw_right_panel(f, chunks[2], app);
}
fn draw_providers(f: &mut Frame, area: Rect, app: &mut App) {
let is_focused = app.models_app.focus == Focus::Providers;
let border_style = focus_border(is_focused);
let outer_block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(" Providers ");
let inner_area = outer_block.inner(area);
f.render_widget(outer_block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(inner_area);
let cat_active = app.models_app.provider_category_filter != ProviderCategory::All;
let cat_color = if cat_active {
app.models_app.provider_category_filter.color()
} else {
Color::DarkGray
};
let grp_color = if app.models_app.group_by_category {
Color::Green
} else {
Color::DarkGray
};
let cat_label = if cat_active {
app.models_app.provider_category_filter.short_label()
} else {
"Cat"
};
let filter_line = Line::from(vec![
Span::styled("[5]", Style::default().fg(cat_color)),
Span::raw(format!(" {} ", cat_label)),
Span::styled("[6]", Style::default().fg(grp_color)),
Span::raw(" Grp"),
]);
f.render_widget(Paragraph::new(filter_line), chunks[0]);
let mut items: Vec<ListItem> = Vec::with_capacity(app.models_app.provider_list_items.len());
for item in &app.models_app.provider_list_items {
match item {
ProviderListItem::All => {
let count = app.models_app.filtered_model_count();
let text = format!("All ({})", count);
items.push(ListItem::new(text).style(Style::default().fg(Color::Green)));
}
ProviderListItem::CategoryHeader(cat) => {
let label = cat.label();
let color = cat.color();
let avail = inner_area.width.saturating_sub(2) as usize; let label_len = label.len() + 4; let trailing = if avail > label_len {
"\u{2500}".repeat(avail - label_len)
} else {
String::new()
};
let text = format!("\u{2500}\u{2500} {} {}", label, trailing);
items.push(
ListItem::new(text)
.style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
);
}
ProviderListItem::Provider(idx, count) => {
if let Some((id, _)) = app.providers.get(*idx) {
let cat = provider_category(id);
let initial = &cat.short_label()[..1];
let color = cat.color();
let line = Line::from(vec![
Span::styled(initial, Style::default().fg(color)),
Span::raw(format!(" {} ", id)),
Span::styled(format!("({})", count), Style::default().fg(Color::Gray)),
]);
items.push(ListItem::new(line));
}
}
}
}
let caret = caret(is_focused);
let list = List::new(items)
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(caret);
f.render_stateful_widget(list, chunks[1], &mut app.models_app.provider_list_state);
}
fn draw_models(f: &mut Frame, area: Rect, app: &mut App) {
let is_focused = app.models_app.focus == Focus::Models;
let border_style = focus_border(is_focused);
let models = app.models_app.filtered_models();
let sort_indicator = match app.models_app.sort_order {
SortOrder::Default => String::new(),
_ => {
let arrow = if app.models_app.sort_ascending {
"\u{2191}"
} else {
"\u{2193}"
};
let label = match app.models_app.sort_order {
SortOrder::ReleaseDate => "date",
SortOrder::Cost => "cost",
SortOrder::Context => "ctx",
SortOrder::Default => unreachable!(),
};
format!(" {}{}", arrow, label)
}
};
let filter_indicator = format_filters(
&app.models_app.filters,
app.models_app.provider_category_filter,
);
let provider_label = app
.models_app
.selected_provider_data(&app.providers)
.map(|(_, p)| p.name.as_str())
.unwrap_or("Models");
let title = if app.models_app.search_query.is_empty() && filter_indicator.is_empty() {
format!(" {} ({}){} ", provider_label, models.len(), sort_indicator)
} else if app.models_app.search_query.is_empty() {
format!(
" {} ({}){} [{}] ",
provider_label,
models.len(),
sort_indicator,
filter_indicator
)
} else if filter_indicator.is_empty() {
format!(
" {} ({}) [/{}]{} ",
provider_label,
models.len(),
app.models_app.search_query,
sort_indicator
)
} else {
format!(
" {} ({}) [/{}] [{}]{} ",
provider_label,
models.len(),
app.models_app.search_query,
filter_indicator,
sort_indicator
)
};
let outer_block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title);
let inner_area = outer_block.inner(area);
f.render_widget(outer_block, area);
let caret_w: u16 = 2;
let caps_w: u16 = 5; let input_w: u16 = 8;
let output_w: u16 = 8;
let ctx_w: u16 = 8;
let num_gaps: u16 = 3;
let fixed_w = caret_w + caps_w + input_w + output_w + ctx_w + num_gaps;
let name_width = (inner_area.width.saturating_sub(fixed_w) as usize).max(10);
let header_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let active_header_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let sort_col = match app.models_app.sort_order {
SortOrder::Default => "name",
SortOrder::ReleaseDate => "name",
SortOrder::Cost => "cost",
SortOrder::Context => "context",
};
let cost_style = if sort_col == "cost" {
active_header_style
} else {
header_style
};
let caret = caret(is_focused);
let mut header_spans: Vec<Span> = vec![
Span::raw(" "),
Span::styled("RTFO ", header_style),
Span::styled(
format!("{:<width$}", "Model ID", width = name_width),
if sort_col == "name" {
active_header_style
} else {
header_style
},
),
];
header_spans.push(Span::styled(format!(" {:>8}", "Input"), cost_style));
header_spans.push(Span::styled(format!(" {:>8}", "Output"), cost_style));
header_spans.push(Span::styled(
format!(" {:>8}", "Context"),
if sort_col == "context" {
active_header_style
} else {
header_style
},
));
let mut items: Vec<ListItem> = Vec::with_capacity(models.len() + 1);
items.push(ListItem::new(Line::from(header_spans)));
for (display_idx, entry) in models.iter().enumerate() {
let is_selected = display_idx == app.models_app.selected_model;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let cost = &entry.model.cost;
let input_cost = crate::data::Model::cost_short(cost.as_ref().and_then(|c| c.input));
let output_cost = crate::data::Model::cost_short(cost.as_ref().and_then(|c| c.output));
let ctx = entry.model.context_str();
let prefix = if is_selected { caret } else { " " };
let m = &entry.model;
let (r_ch, r_color) = if m.reasoning {
("R", Color::Cyan)
} else {
("·", Color::DarkGray)
};
let (t_ch, t_color) = if m.tool_call {
("T", Color::Yellow)
} else {
("·", Color::DarkGray)
};
let (f_ch, f_color) = if m.attachment {
("F", Color::Magenta)
} else {
("·", Color::DarkGray)
};
let (o_ch, o_color) = if m.open_weights {
("O", Color::Green)
} else {
("C", Color::Red)
};
let mut row_spans: Vec<Span> = vec![
Span::styled(prefix, style),
Span::styled(r_ch, Style::default().fg(r_color)),
Span::styled(t_ch, Style::default().fg(t_color)),
Span::styled(f_ch, Style::default().fg(f_color)),
Span::styled(o_ch, Style::default().fg(o_color)),
Span::raw(" "),
Span::styled(
format!(
"{:<width$}",
truncate(&entry.id, name_width.saturating_sub(1)),
width = name_width
),
style,
),
];
row_spans.push(Span::styled(format!(" {:>8}", input_cost), style));
row_spans.push(Span::styled(format!(" {:>8}", output_cost), style));
row_spans.push(Span::styled(format!(" {:>8}", ctx), style));
items.push(ListItem::new(Line::from(row_spans)));
}
let list = List::new(items);
let mut state = app.models_app.model_list_state;
f.render_stateful_widget(list, inner_area, &mut state);
}
fn draw_provider_detail(f: &mut Frame, area: Rect, lines: Vec<Line<'static>>) {
let paragraph = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Provider "))
.wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
}
fn section_header_line(width: u16, title: &str) -> Line<'static> {
let w = width as usize;
let prefix = format!("\u{2500}\u{2500} {} ", title);
let fill_len = w.saturating_sub(prefix.chars().count());
let header = format!("{}{}", prefix, "\u{2500}".repeat(fill_len));
Line::from(Span::styled(
header,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))
}
struct LabelValue<'a> {
label: &'a str,
value: &'a str,
color: Color,
}
fn two_pair_line(left: LabelValue<'_>, right: LabelValue<'_>, col_w: usize) -> Line<'static> {
let label_color = Color::Gray;
let pad1 = col_w.saturating_sub(left.label.len() + left.value.len());
let pad2 = col_w.saturating_sub(right.label.len() + right.value.len());
Line::from(vec![
Span::styled(left.label.to_string(), Style::default().fg(label_color)),
Span::styled(left.value.to_string(), Style::default().fg(left.color)),
Span::raw(" ".repeat(pad1)),
Span::styled(right.label.to_string(), Style::default().fg(label_color)),
Span::styled(right.value.to_string(), Style::default().fg(right.color)),
Span::raw(" ".repeat(pad2)),
])
}
fn model_detail_lines(app: &App, width: u16) -> Vec<Line<'static>> {
let Some(entry) = app.models_app.current_model() else {
return vec![Line::from(Span::styled(
"No model selected",
Style::default().fg(Color::DarkGray),
))];
};
let model = &entry.model;
let provider_id = &entry.provider_id;
let is_deprecated = model.status.as_deref() == Some("deprecated");
let text_color = if is_deprecated {
Color::DarkGray
} else {
Color::White
};
let label_color = Color::Gray;
let em = EM_DASH;
let col_w = (width as usize) / 2;
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
model.name.clone(),
Style::default().fg(text_color).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
entry.id.clone(),
Style::default().fg(Color::DarkGray),
)));
let mut provider_spans = vec![
Span::styled("Provider: ", Style::default().fg(label_color)),
Span::styled(provider_id.clone(), Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("Family: ", Style::default().fg(label_color)),
Span::raw(model.family.clone().unwrap_or_else(|| em.to_string())),
];
if let Some(status) = model.status.as_deref() {
if status != "active" {
let status_color = if status == "deprecated" {
Color::Red
} else {
Color::DarkGray
};
provider_spans.push(Span::raw(" "));
provider_spans.push(Span::styled("Status: ", Style::default().fg(label_color)));
provider_spans.push(Span::styled(
status.to_string(),
Style::default().fg(status_color),
));
}
}
lines.push(Line::from(provider_spans));
lines.push(Line::from(""));
lines.push(section_header_line(width, "Capabilities"));
let cap_val = |active: bool, color: Color| -> (&'static str, Color) {
if active {
("Yes", color)
} else {
("No", Color::DarkGray)
}
};
let (r_val, r_col) = cap_val(model.reasoning, Color::Cyan);
let (t_val, t_col) = cap_val(model.tool_call, Color::Yellow);
let (f_val, f_col) = cap_val(model.attachment, Color::Magenta);
let (ow_val, ow_col) = if model.open_weights {
("Open", Color::Green)
} else {
("Closed", Color::Red)
};
let (tmp_val, tmp_col) = cap_val(model.temperature, Color::White);
lines.push(two_pair_line(
LabelValue {
label: "Reasoning: ",
value: r_val,
color: r_col,
},
LabelValue {
label: "Tools: ",
value: t_val,
color: t_col,
},
col_w,
));
lines.push(two_pair_line(
LabelValue {
label: "Source: ",
value: ow_val,
color: ow_col,
},
LabelValue {
label: "Files: ",
value: f_val,
color: f_col,
},
col_w,
));
lines.push(two_pair_line(
LabelValue {
label: "Temp: ",
value: tmp_val,
color: tmp_col,
},
LabelValue {
label: "",
value: "",
color: Color::DarkGray,
},
col_w,
));
lines.push(Line::from(""));
lines.push(section_header_line(width, "Pricing"));
let free = model.is_free();
let cost_color = if free { Color::Green } else { text_color };
let fmt_cost = |val: Option<f64>| -> (String, Color) {
match val {
None => {
if free {
("Free".to_string(), Color::Green)
} else {
(em.to_string(), Color::DarkGray)
}
}
Some(0.0) => ("$0/M".to_string(), Color::Green),
Some(v) => {
let formatted = if v.fract() == 0.0 {
format!("${}/M", v as u64)
} else {
format!("${:.2}/M", v)
};
(formatted, cost_color)
}
}
};
let (input_str, input_color) = fmt_cost(model.cost.as_ref().and_then(|c| c.input));
let (output_str, output_color) = fmt_cost(model.cost.as_ref().and_then(|c| c.output));
let (cache_read_str, cache_read_color) =
fmt_cost(model.cost.as_ref().and_then(|c| c.cache_read));
let (cache_write_str, cache_write_color) =
fmt_cost(model.cost.as_ref().and_then(|c| c.cache_write));
lines.push(two_pair_line(
LabelValue {
label: "Input: ",
value: &input_str,
color: input_color,
},
LabelValue {
label: "Output: ",
value: &output_str,
color: output_color,
},
col_w,
));
lines.push(two_pair_line(
LabelValue {
label: "Cache Read: ",
value: &cache_read_str,
color: cache_read_color,
},
LabelValue {
label: "Cache Write: ",
value: &cache_write_str,
color: cache_write_color,
},
col_w,
));
lines.push(Line::from(""));
lines.push(section_header_line(width, "Limits"));
let ctx_str = model.context_str();
let inp_lim_str = model.input_limit_str();
let out_str = model.output_str();
let (ctx_val, ctx_color) = if ctx_str == "-" {
(em.to_string(), Color::DarkGray)
} else {
(ctx_str, text_color)
};
let (inp_lim_val, inp_lim_color) = if inp_lim_str == "-" {
(em.to_string(), Color::DarkGray)
} else {
(inp_lim_str, text_color)
};
let (out_val, out_color) = if out_str == "-" {
(em.to_string(), Color::DarkGray)
} else {
(out_str, text_color)
};
let third_w = (width as usize) / 3;
let pad_ctx = third_w.saturating_sub("Context: ".len() + ctx_val.len());
let pad_inp = third_w.saturating_sub("Input: ".len() + inp_lim_val.len());
lines.push(Line::from(vec![
Span::styled("Context: ", Style::default().fg(label_color)),
Span::styled(ctx_val, Style::default().fg(ctx_color)),
Span::raw(" ".repeat(pad_ctx)),
Span::styled("Input: ", Style::default().fg(label_color)),
Span::styled(inp_lim_val, Style::default().fg(inp_lim_color)),
Span::raw(" ".repeat(pad_inp)),
Span::styled("Output: ", Style::default().fg(label_color)),
Span::styled(out_val, Style::default().fg(out_color)),
]));
lines.push(Line::from(""));
lines.push(section_header_line(width, "Modalities"));
let (mod_in, mod_out) = match &model.modalities {
Some(m) => (
if m.input.is_empty() {
"text".to_string()
} else {
m.input.join(", ")
},
if m.output.is_empty() {
"text".to_string()
} else {
m.output.join(", ")
},
),
None => ("text".to_string(), "text".to_string()),
};
lines.push(Line::from(vec![
Span::styled("Input: ", Style::default().fg(label_color)),
Span::styled(mod_in, Style::default().fg(text_color)),
]));
lines.push(Line::from(vec![
Span::styled("Output: ", Style::default().fg(label_color)),
Span::styled(mod_out, Style::default().fg(text_color)),
]));
lines.push(Line::from(""));
lines.push(section_header_line(width, "Dates"));
let released = model.release_date.as_deref().unwrap_or(em);
let knowledge = model.knowledge.as_deref().unwrap_or(em);
let rel_color = if released == em {
Color::DarkGray
} else {
text_color
};
let know_color = if knowledge == em {
Color::DarkGray
} else {
text_color
};
lines.push(two_pair_line(
LabelValue {
label: "Released: ",
value: released,
color: rel_color,
},
LabelValue {
label: "Knowledge: ",
value: knowledge,
color: know_color,
},
col_w,
));
if let Some(updated) = &model.last_updated {
let upd_color = if is_deprecated {
Color::DarkGray
} else {
text_color
};
lines.push(two_pair_line(
LabelValue {
label: "Updated: ",
value: updated,
color: upd_color,
},
LabelValue {
label: "",
value: "",
color: Color::DarkGray,
},
col_w,
));
}
lines
}
fn draw_model_detail(f: &mut Frame, area: Rect, app: &App) {
let focused = app.models_app.focus == Focus::Details;
let inner_w = area.width.saturating_sub(2);
let lines = model_detail_lines(app, inner_w);
ScrollablePanel::new("Details", lines, &app.models_app.detail_scroll, focused).render(f, area);
}
pub(super) fn format_filters(filters: &Filters, category: ProviderCategory) -> String {
let mut active = Vec::new();
if filters.reasoning {
active.push("reasoning");
}
if filters.tools {
active.push("tools");
}
if filters.open_weights {
active.push("open");
}
if filters.free {
active.push("free");
}
if category != ProviderCategory::All {
active.push(category.label());
}
active.join(", ")
}