Skip to main content

koda_cli/widgets/
model_menu.rs

1//! Model picker dropdown — shows curated aliases + auto-detected local models.
2//!
3//! Appears when the user types `/model` with no args.
4
5use super::dropdown::DropdownItem;
6
7/// A model item for the dropdown — either an alias or a local model.
8#[derive(Clone, Debug)]
9pub struct ModelItem {
10    /// Display name (alias name or local model ID).
11    pub label: String,
12    /// Resolved model ID (e.g. "gemini-2.0-flash-lite").
13    pub model_id: String,
14    /// Provider name for display (e.g. "Gemini").
15    pub provider: String,
16    /// Whether this is the currently active model.
17    pub is_current: bool,
18    /// Whether this is the special "local" auto-detect alias.
19    pub is_local: bool,
20}
21
22impl DropdownItem for ModelItem {
23    fn label(&self) -> &str {
24        &self.label
25    }
26    fn description(&self) -> String {
27        let mut parts = Vec::new();
28        if self.label != self.model_id {
29            parts.push(self.model_id.clone());
30        }
31        parts.push(self.provider.clone());
32        if self.is_current {
33            parts.push("\u{25c0} current".to_string());
34        }
35        parts.join("  ")
36    }
37    fn matches_filter(&self, filter: &str) -> bool {
38        let f = filter.to_lowercase();
39        self.label.to_lowercase().contains(&f)
40            || self.model_id.to_lowercase().contains(&f)
41            || self.provider.to_lowercase().contains(&f)
42    }
43}
44
45/// Build model items from aliases + optional local model.
46pub fn build_items(current_model: &str, local_model: Option<&str>) -> Vec<ModelItem> {
47    let mut items: Vec<ModelItem> = koda_core::model_alias::all()
48        .iter()
49        .map(|a| {
50            let provider_name = a.provider.to_string();
51            ModelItem {
52                label: a.alias.to_string(),
53                model_id: a.model_id.to_string(),
54                provider: provider_name,
55                is_current: a.model_id == current_model || a.alias == current_model,
56                is_local: false,
57            }
58        })
59        .collect();
60
61    // Add local model (LMStudio/Ollama auto-detect)
62    match local_model {
63        Some(id) => items.push(ModelItem {
64            label: "local".to_string(),
65            model_id: id.to_string(),
66            provider: "LM Studio".to_string(),
67            is_current: id == current_model,
68            is_local: true,
69        }),
70        None => items.push(ModelItem {
71            label: "local".to_string(),
72            model_id: "(not running)".to_string(),
73            provider: "LM Studio".to_string(),
74            is_current: false,
75            is_local: true,
76        }),
77    }
78
79    items
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn current_model_shows_marker() {
88        let item = ModelItem {
89            label: "claude-sonnet".into(),
90            model_id: "claude-sonnet-4-6".into(),
91            provider: "Anthropic".into(),
92            is_current: true,
93            is_local: false,
94        };
95        assert!(item.description().contains('\u{25c0}'));
96    }
97
98    #[test]
99    fn alias_shows_model_id_and_provider() {
100        let item = ModelItem {
101            label: "gemini-flash-lite".into(),
102            model_id: "gemini-flash-lite-latest".into(),
103            provider: "Gemini".into(),
104            is_current: false,
105            is_local: false,
106        };
107        let desc = item.description();
108        assert!(desc.contains("gemini-flash-lite-latest"));
109        assert!(desc.contains("Gemini"));
110    }
111
112    #[test]
113    fn filter_matches_alias_model_provider() {
114        let item = ModelItem {
115            label: "claude-sonnet".into(),
116            model_id: "claude-sonnet-4-6".into(),
117            provider: "Anthropic".into(),
118            is_current: false,
119            is_local: false,
120        };
121        assert!(item.matches_filter("sonnet"));
122        assert!(item.matches_filter("anthropic"));
123        assert!(item.matches_filter("4-6"));
124        assert!(!item.matches_filter("gemini"));
125    }
126
127    #[test]
128    fn build_items_includes_local() {
129        let items = build_items("gpt-4o", None);
130        let local = items.iter().find(|i| i.label == "local").unwrap();
131        assert!(local.is_local);
132        assert_eq!(local.model_id, "(not running)");
133    }
134
135    #[test]
136    fn build_items_with_local_model() {
137        let items = build_items("qwen3-8b", Some("qwen3-8b"));
138        let local = items.iter().find(|i| i.label == "local").unwrap();
139        assert!(local.is_current);
140        assert_eq!(local.model_id, "qwen3-8b");
141    }
142
143    #[test]
144    fn build_items_marks_current_by_alias() {
145        let items = build_items("claude-sonnet", None);
146        let current = items.iter().find(|i| i.is_current);
147        assert!(current.is_some());
148        assert_eq!(current.unwrap().label, "claude-sonnet");
149    }
150
151    #[test]
152    fn build_items_marks_current_by_model_id() {
153        let items = build_items("claude-sonnet-4-6", None);
154        let current = items.iter().find(|i| i.is_current);
155        assert!(current.is_some());
156        assert_eq!(current.unwrap().label, "claude-sonnet");
157    }
158}