use anyhow::Result;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
pub mod agents;
pub mod app;
pub mod benchmarks;
pub mod event;
pub mod markdown;
pub mod models;
pub mod status;
pub mod ui;
pub mod widgets;
use crate::agents::{
load_agents, AsyncGitHubClient, ConditionalFetchResult, GitHubCache, GitHubData,
};
use crate::benchmarks::{BenchmarkFetchResult, BenchmarkFetcher, BenchmarkStore};
use crate::config::Config;
use crate::data::ProvidersMap;
use crate::status::{StatusFetchResult, StatusFetcher};
use std::sync::Arc;
use tokio::sync::RwLock;
fn copy_to_clipboard(text: String) {
std::thread::spawn(move || {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(&text);
std::thread::sleep(std::time::Duration::from_secs(2));
}
});
}
#[derive(Debug)]
pub enum FetchResult {
Success(String, GitHubData),
Failure(String, String),
}
struct StatusRuntime {
rx: mpsc::Receiver<(u64, StatusFetchResult)>,
tx: mpsc::Sender<(u64, StatusFetchResult)>,
client: reqwest::Client,
last_fetch_time: Option<Instant>,
fetch_generation: u64,
}
struct RuntimeHandles {
github_rx: mpsc::Receiver<FetchResult>,
github_tx: mpsc::Sender<FetchResult>,
client: AsyncGitHubClient,
disk_cache: Arc<RwLock<GitHubCache>>,
bench_rx: mpsc::Receiver<BenchmarkFetchResult>,
status: StatusRuntime,
}
pub async fn run(providers: ProvidersMap) -> Result<()> {
use crate::agents::FetchStatus;
let agents_file = load_agents().ok();
let config = Config::load().ok();
let benchmark_store = BenchmarkStore::empty();
let disk_cache = GitHubCache::load();
let mut app = app::App::new(providers, agents_file.as_ref(), config, benchmark_store);
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
original_hook(panic_info);
}));
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
if let Some(ref mut agents_app) = app.agents_app {
for entry in &mut agents_app.entries {
if entry.tracked {
if let Some(cached) = disk_cache.get(&entry.agent.repo) {
entry.github = cached.data.clone().into();
entry.fetch_status = FetchStatus::Loaded;
}
}
}
agents_app.apply_sort();
}
let disk_cache = Arc::new(RwLock::new(disk_cache));
let token = crate::agents::github::detect_github_token();
let client = AsyncGitHubClient::with_disk_cache(token, disk_cache.clone());
let (tx, rx) = mpsc::channel(100);
let fetch_handles = if let Some(ref agents_app) = app.agents_app {
let tracked_entries: Vec<_> = agents_app.entries.iter().filter(|e| e.tracked).collect();
let mut handles = Vec::with_capacity(tracked_entries.len());
for entry in tracked_entries {
let tx = tx.clone();
let client = client.clone();
let id = entry.id.clone();
let repo = entry.agent.repo.clone();
let cache = disk_cache.clone();
let handle = tokio::spawn(async move {
let result = match client.fetch_conditional(&repo).await {
ConditionalFetchResult::Fresh(data, _etag) => FetchResult::Success(id, data),
ConditionalFetchResult::NotModified => {
let cache_guard = cache.read().await;
if let Some(cached) = cache_guard.get(&repo) {
FetchResult::Success(id, cached.data.clone().into())
} else {
FetchResult::Failure(id, "Cache miss on NotModified".to_string())
}
}
ConditionalFetchResult::Error(e) => FetchResult::Failure(id, e),
};
let _ = tx.send(result).await;
});
handles.push(handle);
}
handles
} else {
Vec::new()
};
let (bench_tx, bench_rx) = mpsc::channel(1);
tokio::spawn(async move {
let fetcher = BenchmarkFetcher::new();
let result = fetcher.fetch().await;
let _ = bench_tx.send(result).await;
});
let (status_tx, status_rx) = mpsc::channel(4);
let status_client = reqwest::Client::builder()
.user_agent("models-tui")
.connect_timeout(Duration::from_secs(5))
.build()
.expect("Failed to build HTTP client");
if let Some(ref status_app) = app.status_app {
let seeds = status_app.fetch_seeds();
let tx = status_tx.clone();
let fetcher = StatusFetcher::with_client(status_client.clone());
tokio::spawn(async move {
let result = fetcher.fetch(&seeds).await;
let _ = tx.send((0, result)).await;
});
}
let status_runtime = StatusRuntime {
rx: status_rx,
tx: status_tx,
client: status_client,
last_fetch_time: None,
fetch_generation: 0,
};
let runtime_handles = RuntimeHandles {
github_rx: rx,
github_tx: tx,
client,
disk_cache: disk_cache.clone(),
bench_rx,
status: status_runtime,
};
let result = run_app(&mut terminal, &mut app, runtime_handles);
for handle in fetch_handles {
handle.abort();
}
if let Ok(cache_guard) = disk_cache.try_read() {
let _ = cache_guard.save();
}
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
result
}
fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut app::App,
mut runtime: RuntimeHandles,
) -> Result<()> {
let mut last_status_time: Option<std::time::Instant> = None;
loop {
terminal.draw(|f| ui::draw(f, app))?;
if let Some(time) = last_status_time {
if time.elapsed() > std::time::Duration::from_secs(2) {
app.clear_status();
last_status_time = None;
}
}
if !app.pending_fetches.is_empty() {
let fetches = std::mem::take(&mut app.pending_fetches);
for (agent_id, repo) in fetches {
let tx = runtime.github_tx.clone();
let client = runtime.client.clone();
let cache = runtime.disk_cache.clone();
tokio::spawn(async move {
let result = match client.fetch_conditional(&repo).await {
ConditionalFetchResult::Fresh(data, _etag) => {
FetchResult::Success(agent_id, data)
}
ConditionalFetchResult::NotModified => {
let cache_guard = cache.read().await;
if let Some(cached) = cache_guard.get(&repo) {
FetchResult::Success(agent_id, cached.data.clone().into())
} else {
FetchResult::Failure(
agent_id,
"Cache miss on NotModified".to_string(),
)
}
}
ConditionalFetchResult::Error(e) => FetchResult::Failure(agent_id, e),
};
let _ = tx.send(result).await;
});
}
}
while let Ok(result) = runtime.github_rx.try_recv() {
match result {
FetchResult::Success(id, data) => {
app.update(app::Message::GitHubDataReceived(id, data));
}
FetchResult::Failure(id, error) => {
app.update(app::Message::GitHubFetchFailed(id, error));
}
}
}
if let Ok(result) = runtime.bench_rx.try_recv() {
match result {
BenchmarkFetchResult::Fresh(entries) => {
app.update(app::Message::BenchmarkDataReceived(entries));
}
BenchmarkFetchResult::Error => {
app.update(app::Message::BenchmarkFetchFailed);
}
}
}
if app.pending_status_refresh {
app.pending_status_refresh = false;
let force = app.force_status_refresh;
app.force_status_refresh = false;
let stale = runtime
.status
.last_fetch_time
.is_none_or(|t| t.elapsed() > Duration::from_secs(60));
let recent = runtime
.status
.last_fetch_time
.is_some_and(|t| t.elapsed() < Duration::from_secs(2));
if force || (stale && !recent) {
if let Some(ref status_app) = app.status_app {
runtime.status.fetch_generation += 1;
let gen = runtime.status.fetch_generation;
runtime.status.last_fetch_time = Some(Instant::now());
let seeds = status_app.fetch_seeds();
let tx = runtime.status.tx.clone();
let fetcher = StatusFetcher::with_client(runtime.status.client.clone());
tokio::spawn(async move {
let result = fetcher.fetch(&seeds).await;
let _ = tx.send((gen, result)).await;
});
}
} else if let Some(ref mut status_app) = app.status_app {
status_app.loading = false;
}
}
if let Ok((gen, result)) = runtime.status.rx.try_recv() {
if gen >= runtime.status.fetch_generation {
let StatusFetchResult::Fresh(entries) = result;
app.update(app::Message::StatusDataReceived(entries));
}
}
if let Some(msg) = event::handle_events(app)? {
match &msg {
app::Message::CopyFull => {
if let Some(text) = app.get_copy_full() {
copy_to_clipboard(text.clone());
app.set_status(format!("Copied: {}", text));
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::CopyModelId => {
if let Some(text) = app.get_copy_model_id() {
copy_to_clipboard(text.clone());
app.set_status(format!("Copied: {}", text));
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::CopyProviderDoc => {
if let Some(text) = app.get_provider_doc() {
copy_to_clipboard(text.clone());
app.set_status(format!("Copied: {}", text));
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::CopyProviderApi => {
if let Some(text) = app.get_provider_api() {
copy_to_clipboard(text.clone());
app.set_status(format!("Copied: {}", text));
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::OpenProviderDoc => {
if let Some(url) = app.get_provider_doc() {
let _ = open::that_in_background(&url);
app.set_status(format!("Opened: {}", url));
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::OpenAgentDocs => {
if let Some(ref agents_app) = app.agents_app {
if let Some(entry) = agents_app.current_entry() {
if let Some(ref url) = entry.agent.docs {
let _ = open::that_in_background(url);
app.set_status(format!("Opened: {}", url));
last_status_time = Some(std::time::Instant::now());
} else if let Some(ref url) = entry.agent.homepage {
let _ = open::that_in_background(url);
app.set_status(format!("Opened: {}", url));
last_status_time = Some(std::time::Instant::now());
}
}
}
}
app::Message::OpenAgentRepo => {
if let Some(ref agents_app) = app.agents_app {
if let Some(entry) = agents_app.current_entry() {
let url = format!("https://github.com/{}", entry.agent.repo);
let _ = open::that_in_background(&url);
app.set_status(format!("Opened: {}", url));
last_status_time = Some(std::time::Instant::now());
}
}
}
app::Message::CopyAgentName => {
if let Some(ref agents_app) = app.agents_app {
if let Some(entry) = agents_app.current_entry() {
copy_to_clipboard(entry.agent.name.clone());
app.set_status(format!("Copied: {}", entry.agent.name));
last_status_time = Some(std::time::Instant::now());
}
}
}
app::Message::CopyBenchmarkName => {
if let Some(entry) = app.benchmarks_app.current_entry(&app.benchmark_store) {
copy_to_clipboard(entry.name.clone());
app.set_status(format!("Copied: {}", entry.name));
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::OpenBenchmarkUrl => {
if let Some(entry) = app.benchmarks_app.current_entry(&app.benchmark_store) {
let url = format!("https://artificialanalysis.ai/models/{}", entry.slug);
let _ = open::that_in_background(&url);
app.set_status(format!("Opened: {}", url));
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::OpenStatusPage => {
if let Some(entry) = app.status_app.as_ref().and_then(|a| a.current_entry()) {
if let Some(url) = entry.best_open_url() {
let _ = open::that_in_background(url);
app.set_status(format!("Opened: {}", url));
last_status_time = Some(std::time::Instant::now());
}
}
}
app::Message::RefreshStatus => {
app.set_status("Refreshing provider status…".to_string());
last_status_time = Some(std::time::Instant::now());
}
app::Message::PickerSave => {
last_status_time = Some(std::time::Instant::now());
}
app::Message::ToggleBenchmarkSelection => {
if let Some(&store_idx) = app
.benchmarks_app
.filtered_indices
.get(app.benchmarks_app.selected)
{
let name = app
.benchmark_store
.entries()
.get(store_idx)
.map(|e| e.name.as_str())
.unwrap_or("?");
let is_already_selected = app.selections.contains(&store_idx);
if is_already_selected {
let count = app.selections.len() - 1;
app.set_status(format!(
"Removed {} ({}/{})",
name,
count,
app::MAX_SELECTIONS
));
} else if app.selections.len() < app::MAX_SELECTIONS {
let count = app.selections.len() + 1;
app.set_status(format!(
"Added {} ({}/{})",
name,
count,
app::MAX_SELECTIONS
));
}
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::ClearBenchmarkSelections => {
let count = app.selections.len();
if count > 0 {
app.set_status(format!(
"Cleared {} selection{}",
count,
if count == 1 { "" } else { "s" }
));
last_status_time = Some(std::time::Instant::now());
}
}
app::Message::CycleBenchmarkView => {
let next_view = match app.benchmarks_app.bottom_view {
crate::tui::benchmarks::BottomView::H2H => "Scatter",
crate::tui::benchmarks::BottomView::Scatter => "Radar",
crate::tui::benchmarks::BottomView::Radar => "H2H",
crate::tui::benchmarks::BottomView::Detail => "H2H",
};
app.set_status(format!("View: {}", next_view));
last_status_time = Some(std::time::Instant::now());
}
app::Message::CycleScatterX => {
let next_axis = app.benchmarks_app.scatter_x.next();
app.set_status(format!("X-axis: {}", next_axis.label()));
last_status_time = Some(std::time::Instant::now());
}
app::Message::CycleScatterY => {
let next_axis = app.benchmarks_app.scatter_y.next();
app.set_status(format!("Y-axis: {}", next_axis.label()));
last_status_time = Some(std::time::Instant::now());
}
app::Message::CycleRadarPreset => {
let next_preset = app.benchmarks_app.radar_preset.next();
app.set_status(format!("Radar: {}", next_preset.label()));
last_status_time = Some(std::time::Instant::now());
}
_ => {}
}
if !app.update(msg) {
return Ok(());
}
}
}
}