1use acp_utils::notifications::{McpServerStatus, McpServerStatusEntry};
2use tui::{Component, Event, Frame, Line, SelectItem, SelectList, SelectListMessage, ViewContext};
3
4struct ServerItem(McpServerStatusEntry);
5
6impl SelectItem for ServerItem {
7 fn render_item(&self, selected: bool, context: &ViewContext) -> Line {
8 let (indicator, detail) = match &self.0.status {
9 McpServerStatus::Connected { tool_count } if self.0.can_authenticate() => {
10 ("✓", format!("{tool_count} tools, authenticated"))
11 }
12 McpServerStatus::Connected { tool_count } => ("✓", format!("{tool_count} tools")),
13 McpServerStatus::Failed { error } => ("✗", error.clone()),
14 McpServerStatus::NeedsOAuth => ("⚡", "needs authentication".to_string()),
15 };
16 let text = format!("{} {indicator} {detail}", self.0.name);
17 match &self.0.status {
18 McpServerStatus::Connected { .. } => {
19 if selected {
20 Line::with_style(text, context.theme.selected_row_style())
21 } else {
22 Line::new(text)
23 }
24 }
25 McpServerStatus::Failed { .. } => {
26 if selected {
27 Line::with_style(text, context.theme.selected_row_style_with_fg(context.theme.error()))
28 } else {
29 Line::styled(text, context.theme.error())
30 }
31 }
32 McpServerStatus::NeedsOAuth => {
33 if selected {
34 Line::with_style(text, context.theme.selected_row_style_with_fg(context.theme.warning()))
35 } else {
36 Line::styled(text, context.theme.warning())
37 }
38 }
39 }
40 }
41}
42
43pub struct ServerStatusOverlay {
44 list: SelectList<ServerItem>,
45}
46
47pub enum ServerStatusMessage {
48 Close,
49 Authenticate(String),
50}
51
52impl Component for ServerStatusOverlay {
53 type Message = ServerStatusMessage;
54
55 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
56 let outcome = self.list.on_event(event).await;
57 match outcome.as_deref() {
58 Some([SelectListMessage::Close]) => Some(vec![ServerStatusMessage::Close]),
59 Some([SelectListMessage::Select(_)]) => {
60 if let Some(item) = self.list.selected_item()
61 && item.0.can_authenticate()
62 {
63 return Some(vec![ServerStatusMessage::Authenticate(item.0.name.clone())]);
64 }
65 Some(vec![])
66 }
67 _ => outcome.map(|_| vec![]),
68 }
69 }
70
71 fn render(&mut self, context: &ViewContext) -> Frame {
72 self.list.render(context)
73 }
74}
75
76pub fn server_status_summary(statuses: &[McpServerStatusEntry]) -> String {
77 if statuses.is_empty() {
78 return "none".to_string();
79 }
80 let (mut c, mut n, mut f) = (0usize, 0usize, 0usize);
81 for s in statuses {
82 match &s.status {
83 McpServerStatus::Connected { .. } => c += 1,
84 McpServerStatus::NeedsOAuth => n += 1,
85 McpServerStatus::Failed { .. } => f += 1,
86 }
87 }
88 [(c, "connected"), (n, "needs auth"), (f, "failed")]
89 .iter()
90 .filter(|(count, _)| *count > 0)
91 .map(|(count, label)| format!("{count} {label}"))
92 .collect::<Vec<_>>()
93 .join(", ")
94}
95
96impl ServerStatusOverlay {
97 pub fn new(entries: Vec<McpServerStatusEntry>) -> Self {
98 let items: Vec<ServerItem> = entries.into_iter().map(ServerItem).collect();
99 Self { list: SelectList::new(items, "no MCP servers configured") }
100 }
101
102 pub fn update_entries(&mut self, entries: Vec<McpServerStatusEntry>) {
103 let prev_index = self.list.selected_index();
104 let items: Vec<ServerItem> = entries.into_iter().map(ServerItem).collect();
105 self.list.set_items(items);
106 let max = self.list.len().saturating_sub(1);
107 self.list.set_selected(prev_index.min(max));
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use acp_utils::notifications::McpServerAuthCapability;
115
116 fn sample_entries() -> Vec<McpServerStatusEntry> {
117 vec![
118 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
119 McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
120 .with_auth_capability(McpServerAuthCapability::OAuth),
121 McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
122 ]
123 }
124
125 #[test]
126 fn renders_all_entries_with_status_indicators() {
127 let mut overlay = ServerStatusOverlay::new(sample_entries());
128 let ctx = ViewContext::new((80, 24));
129 let frame = overlay.render(&ctx);
130
131 assert_eq!(frame.lines().len(), 3);
132 let text0 = frame.lines()[0].plain_text();
133 assert!(text0.contains("github"), "should contain server name");
134 assert!(text0.contains("✓"), "connected should show checkmark");
135 assert!(text0.contains("5 tools"), "should show tool count");
136
137 let text1 = frame.lines()[1].plain_text();
138 assert!(text1.contains("linear"), "should contain server name");
139 assert!(text1.contains("⚡"), "needs auth should show bolt");
140
141 let text2 = frame.lines()[2].plain_text();
142 assert!(text2.contains("slack"), "should contain server name");
143 assert!(text2.contains("✗"), "failed should show X");
144 assert!(text2.contains("connection timeout"), "should show error");
145 }
146
147 #[test]
148 fn selected_entry_has_pointer() {
149 let mut overlay = ServerStatusOverlay::new(sample_entries());
150 let ctx = ViewContext::new((80, 24));
151 let frame = overlay.render(&ctx);
152
153 assert!(frame.lines()[0].plain_text().starts_with(" "));
154 assert!(frame.lines()[1].plain_text().starts_with(" "));
155 }
156
157 #[tokio::test]
158 async fn navigation_wraps_around() {
159 let mut overlay = ServerStatusOverlay::new(sample_entries());
160
161 overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Up, tui::KeyModifiers::NONE))).await;
162 assert_eq!(overlay.list.selected_index(), 2);
163
164 overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Down, tui::KeyModifiers::NONE))).await;
165 assert_eq!(overlay.list.selected_index(), 0);
166 }
167
168 #[tokio::test]
169 async fn enter_on_needs_oauth_emits_authenticate() {
170 let mut overlay = ServerStatusOverlay::new(sample_entries());
171 overlay.list.set_selected(1); let outcome =
174 overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
175 let messages = outcome.unwrap();
176 match messages.as_slice() {
177 [ServerStatusMessage::Authenticate(name)] => assert_eq!(name, "linear"),
178 _ => panic!("Expected Authenticate message"),
179 }
180 }
181
182 #[tokio::test]
183 async fn enter_on_connected_without_auth_is_noop() {
184 let mut overlay = ServerStatusOverlay::new(sample_entries());
185 let outcome =
188 overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
189 assert!(outcome.unwrap().is_empty());
190 }
191
192 #[tokio::test]
193 async fn enter_on_connected_oauth_server_emits_authenticate_for_reauth() {
194 let mut overlay = ServerStatusOverlay::new(vec![
195 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 })
196 .with_auth_capability(McpServerAuthCapability::OAuth),
197 ]);
198
199 let outcome =
200 overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
201 let messages = outcome.unwrap();
202 match messages.as_slice() {
203 [ServerStatusMessage::Authenticate(name)] => assert_eq!(name, "github"),
204 _ => panic!("Expected Authenticate message"),
205 }
206 }
207
208 #[tokio::test]
209 async fn esc_closes_overlay() {
210 let mut overlay = ServerStatusOverlay::new(sample_entries());
211 let outcome =
212 overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Esc, tui::KeyModifiers::NONE))).await;
213 let messages = outcome.unwrap();
214 assert!(matches!(messages.as_slice(), [ServerStatusMessage::Close]));
215 }
216
217 #[test]
218 fn empty_entries_shows_placeholder() {
219 let mut overlay = ServerStatusOverlay::new(vec![]);
220 let ctx = ViewContext::new((80, 24));
221 let frame = overlay.render(&ctx);
222 assert_eq!(frame.lines().len(), 1);
223 assert!(frame.lines()[0].plain_text().contains("no MCP servers configured"));
224 }
225
226 #[test]
227 fn update_entries_clamps_index() {
228 let mut overlay = ServerStatusOverlay::new(sample_entries());
229 overlay.list.set_selected(2);
230
231 overlay.update_entries(vec![McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 3 })]);
232 assert_eq!(overlay.list.selected_index(), 0);
233 }
234}