diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 10e60c68d..5240fd4a0 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -1887,7 +1887,10 @@ fn run_models_report( entry.input + entry.output + entry.cache_read + entry.cache_write; table.add_row(vec![ Cell::new(capitalized_clients), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) .set_alignment(CellAlignment::Right), @@ -1946,7 +1949,10 @@ fn run_models_report( entry.input + entry.output + entry.cache_read + entry.cache_write; table.add_row(vec![ Cell::new(capitalize_client(&entry.client)), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) .set_alignment(CellAlignment::Right), @@ -2118,7 +2124,10 @@ fn run_models_report( .join(", "); table.add_row(vec![ Cell::new(capitalized_clients), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) .set_alignment(CellAlignment::Right), @@ -2206,7 +2215,10 @@ fn run_models_report( } row.extend([ Cell::new(session_label), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) .set_alignment(CellAlignment::Right), @@ -2285,7 +2297,10 @@ fn run_models_report( table.add_row(vec![ Cell::new(capitalize_client(&entry.client)), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_model_name(&entry.model)), Cell::new(format_tokens_with_commas(entry.input)) @@ -2371,7 +2386,10 @@ fn run_models_report( table.add_row(vec![ Cell::new(workspace_name(entry.workspace_label.as_deref())), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(capitalized_clients), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) diff --git a/crates/tokscale-cli/src/tui/mod.rs b/crates/tokscale-cli/src/tui/mod.rs index 35470d268..44026ea70 100644 --- a/crates/tokscale-cli/src/tui/mod.rs +++ b/crates/tokscale-cli/src/tui/mod.rs @@ -11,7 +11,7 @@ pub(crate) mod privacy; pub mod remote; pub mod settings; mod themes; -mod ui; +pub(crate) mod ui; pub use app::{App, Tab, TuiConfig}; pub use cache::{ diff --git a/crates/tokscale-cli/src/tui/ui/widgets.rs b/crates/tokscale-cli/src/tui/ui/widgets.rs index 7c135befa..84ad8536b 100644 --- a/crates/tokscale-cli/src/tui/ui/widgets.rs +++ b/crates/tokscale-cli/src/tui/ui/widgets.rs @@ -375,19 +375,65 @@ pub fn get_provider_display_name(provider: &str) -> String { if let Some(name) = config.get_provider_display_name(provider) { return name.to_string(); } - match provider.to_lowercase().as_str() { - "anthropic" => "Anthropic".to_string(), + let lower = provider.to_lowercase(); + match lower.as_str() { + "anthropic" => return "Anthropic".to_string(), + "google" => return "Google".to_string(), + "cursor" => return "Cursor".to_string(), + "deepseek" => return "DeepSeek".to_string(), + "xai" => return "xAI".to_string(), + "meta" => return "Meta".to_string(), + "mistral" => return "Mistral".to_string(), + "cohere" => return "Cohere".to_string(), + "opencode" => return "OpenCode".to_string(), + // `canonical_provider` rewrites `google-vertex` → `google_vertex`, so + // accept both spellings here. + "google-vertex" | "google_vertex" => return "Google Vertex".to_string(), + _ => {} + } + + // Brand families: any provider id that starts with these stems collapses to + // the brand name. Covers `openai`, `openai-codex`, `kimi`, `kimi-code`, + // `kimi-for-coding`, etc. without enumerating every variant. + if lower.starts_with("openai") { + return "OpenAI".to_string(); + } + if lower.starts_with("kimi") { + return "Kimi".to_string(); + } + if lower.starts_with("github-cop") || lower.contains("copilot") { + return "GitHub Copilot".to_string(); + } + + // Smart fallback: split on `-`, `_`, and whitespace, title-case each word, + // and map known acronyms/brands per word. So unknown multi-word providers + // like `google-vertex` → "Google Vertex" and `some-new-provider` → + // "Some New Provider". + smart_titlecase(provider) +} + +/// Title-cases a provider/brand identifier word-by-word, splitting on `-`, `_`, +/// and whitespace. Per-word acronym/brand overrides (e.g. `ai` → "AI", +/// `gpt` → "GPT") win over plain capitalization. Empty input yields an empty +/// string; runs of separators are collapsed. +fn smart_titlecase(s: &str) -> String { + s.split(['-', '_', ' ']) + .filter(|word| !word.is_empty()) + .map(titlecase_word) + .collect::>() + .join(" ") +} + +fn titlecase_word(word: &str) -> String { + match word.to_lowercase().as_str() { + "ai" => "AI".to_string(), + "gpt" => "GPT".to_string(), "openai" => "OpenAI".to_string(), - "google" => "Google".to_string(), - "cursor" => "Cursor".to_string(), - "deepseek" => "DeepSeek".to_string(), "xai" => "xAI".to_string(), - "meta" => "Meta".to_string(), - "mistral" => "Mistral".to_string(), - "cohere" => "Cohere".to_string(), - "opencode" => "OpenCode".to_string(), - s if s.starts_with("github-cop") || s.contains("copilot") => "GitHub Copilot".to_string(), - _ => capitalize_first(provider), + "vertex" => "Vertex".to_string(), + "llm" => "LLM".to_string(), + "api" => "API".to_string(), + _ => capitalize_first(word), } } @@ -523,6 +569,83 @@ mod tests { assert_eq!(last, past_end); } + #[test] + fn provider_display_name_target_cases() { + // The four cases the user reported as rendering wrong. + assert_eq!(get_provider_display_name("openai"), "OpenAI"); + assert_eq!(get_provider_display_name("kimi-for-coding"), "Kimi"); + assert_eq!(get_provider_display_name("google-vertex"), "Google Vertex"); + assert_eq!(get_provider_display_name("opencode"), "OpenCode"); + } + + #[test] + fn provider_display_name_openai_family() { + // Any openai* id collapses to the brand name. + assert_eq!(get_provider_display_name("openai"), "OpenAI"); + assert_eq!(get_provider_display_name("openai-codex"), "OpenAI"); + assert_eq!(get_provider_display_name("OpenAI"), "OpenAI"); + } + + #[test] + fn provider_display_name_kimi_family() { + assert_eq!(get_provider_display_name("kimi"), "Kimi"); + assert_eq!(get_provider_display_name("kimi-code"), "Kimi"); + assert_eq!(get_provider_display_name("kimi-for-coding"), "Kimi"); + } + + #[test] + fn provider_display_name_google_vertex_both_spellings() { + // `canonical_provider` rewrites the hyphen to an underscore, so both + // spellings must map to the same clean label. + assert_eq!(get_provider_display_name("google-vertex"), "Google Vertex"); + assert_eq!(get_provider_display_name("google_vertex"), "Google Vertex"); + } + + #[test] + fn provider_display_name_smart_fallback_multiword() { + // Unknown multi-word providers get split + title-cased instead of the + // old naive capitalize-first ("Some-new-provider"). + assert_eq!( + get_provider_display_name("some-new-provider"), + "Some New Provider" + ); + assert_eq!( + get_provider_display_name("some_new_provider"), + "Some New Provider" + ); + } + + #[test] + fn provider_display_name_known_regressions() { + assert_eq!(get_provider_display_name("anthropic"), "Anthropic"); + assert_eq!(get_provider_display_name("google"), "Google"); + assert_eq!(get_provider_display_name("xai"), "xAI"); + assert_eq!(get_provider_display_name("deepseek"), "DeepSeek"); + assert_eq!(get_provider_display_name("meta"), "Meta"); + assert_eq!(get_provider_display_name("mistral"), "Mistral"); + assert_eq!(get_provider_display_name("cohere"), "Cohere"); + assert_eq!(get_provider_display_name("cursor"), "Cursor"); + assert_eq!( + get_provider_display_name("github-copilot"), + "GitHub Copilot" + ); + assert_eq!(get_provider_display_name("copilot"), "GitHub Copilot"); + } + + #[test] + fn provider_display_name_acronym_words_in_fallback() { + // Per-word acronym map applies inside the smart fallback. + assert_eq!(get_provider_display_name("acme-ai"), "Acme AI"); + assert_eq!(get_provider_display_name("foo-api"), "Foo API"); + } + + #[test] + fn provider_display_name_empty_is_empty() { + assert_eq!(get_provider_display_name(""), ""); + // A string of only separators collapses to empty rather than panicking. + assert_eq!(get_provider_display_name("--_-"), ""); + } + #[test] fn get_provider_shade_fuzzy_matching() { assert_eq!(