koda_cli/widgets/
model_menu.rs1use super::dropdown::DropdownItem;
6
7#[derive(Clone, Debug)]
9pub struct ModelItem {
10 pub label: String,
12 pub model_id: String,
14 pub provider: String,
16 pub is_current: bool,
18 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
45pub 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 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}