1use acp_utils::notifications::{McpServerStatus, McpServerStatusEntry};
2use tui::{Component, Event, Frame, Line, SelectItem, SelectList, SelectListMessage, ViewContext};
3
4pub struct ServerStatusOverlay {
5 list: SelectList<ServerStatusRow>,
6}
7
8pub enum ServerStatusMessage {
9 Close,
10 Authenticate(String),
11}
12
13impl Component for ServerStatusOverlay {
14 type Message = ServerStatusMessage;
15
16 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
17 let outcome = self.list.on_event(event).await;
18 match outcome.as_deref() {
19 Some([SelectListMessage::Close]) => Some(vec![ServerStatusMessage::Close]),
20 Some([SelectListMessage::Select(_)]) => {
21 if let Some(ServerStatusRow::Server { entry, .. }) = self.list.selected_item()
22 && entry.can_authenticate()
23 {
24 return Some(vec![ServerStatusMessage::Authenticate(entry.name.clone())]);
25 }
26 Some(vec![])
27 }
28 _ => outcome.map(|_| vec![]),
29 }
30 }
31
32 fn render(&mut self, context: &ViewContext) -> Frame {
33 self.list.render(context)
34 }
35}
36
37pub fn server_status_summary(statuses: &[McpServerStatusEntry]) -> String {
38 if statuses.is_empty() {
39 return "none".to_string();
40 }
41 let mut connected = 0usize;
42 let mut connecting = 0usize;
43 let mut authenticating = 0usize;
44 let mut needs_auth = 0usize;
45 let mut failed = 0usize;
46 for entry in statuses {
47 match &entry.status {
48 McpServerStatus::Connected { .. } => connected += 1,
49 McpServerStatus::Connecting => connecting += 1,
50 McpServerStatus::Authenticating => authenticating += 1,
51 McpServerStatus::NeedsOAuth => needs_auth += 1,
52 McpServerStatus::Failed { .. } => failed += 1,
53 }
54 }
55 [
56 (connected, "connected"),
57 (connecting, "connecting"),
58 (authenticating, "authenticating"),
59 (needs_auth, "needs auth"),
60 (failed, "failed"),
61 ]
62 .iter()
63 .filter(|(count, _)| *count > 0)
64 .map(|(count, label)| format!("{count} {label}"))
65 .collect::<Vec<_>>()
66 .join(", ")
67}
68
69impl ServerStatusOverlay {
70 pub fn new(entries: Vec<McpServerStatusEntry>) -> Self {
71 Self { list: SelectList::new(build_rows(entries), "no MCP servers configured") }
72 }
73
74 pub fn update_entries(&mut self, entries: Vec<McpServerStatusEntry>) {
75 let selected_name = match self.list.selected_item() {
76 Some(ServerStatusRow::Server { entry, .. }) => Some(entry.name.clone()),
77 _ => None,
78 };
79 self.list.set_items(build_rows(entries));
80 if let Some(name) = selected_name {
81 self.list.select_where(|row| matches!(row, ServerStatusRow::Server { entry, .. } if entry.name == name));
82 }
83 }
84}
85
86#[derive(Clone)]
87enum ServerStatusRow {
88 Header(String),
89 Spacer,
90 Server { entry: McpServerStatusEntry, indented: bool },
91}
92
93impl SelectItem for ServerStatusRow {
94 fn render_item(&self, selected: bool, context: &ViewContext) -> Line {
95 match self {
96 ServerStatusRow::Header(label) => Line::new(label.clone()),
97 ServerStatusRow::Spacer => Line::default(),
98 ServerStatusRow::Server { entry, indented } => render_server_entry(entry, selected, *indented, context),
99 }
100 }
101
102 fn is_selectable(&self) -> bool {
103 matches!(self, ServerStatusRow::Server { .. })
104 }
105}
106
107fn build_rows(entries: Vec<McpServerStatusEntry>) -> Vec<ServerStatusRow> {
108 let (proxied, direct): (Vec<_>, Vec<_>) = entries.into_iter().partition(|entry| entry.proxied);
109
110 if proxied.is_empty() {
111 return direct.into_iter().map(|entry| ServerStatusRow::Server { entry, indented: false }).collect();
112 }
113
114 let mut rows = Vec::new();
115 if !direct.is_empty() {
116 rows.push(ServerStatusRow::Header("Direct".to_string()));
117 rows.extend(direct.into_iter().map(|entry| ServerStatusRow::Server { entry, indented: true }));
118 rows.push(ServerStatusRow::Spacer);
119 }
120 rows.push(ServerStatusRow::Header("Proxied".to_string()));
121 rows.extend(proxied.into_iter().map(|entry| ServerStatusRow::Server { entry, indented: true }));
122 rows
123}
124
125fn render_server_entry(entry: &McpServerStatusEntry, selected: bool, indented: bool, context: &ViewContext) -> Line {
126 let (indicator, detail) = match &entry.status {
127 McpServerStatus::Connected { tool_count } if entry.can_authenticate() => {
128 ("✓", format!("{tool_count} tools, authenticated"))
129 }
130 McpServerStatus::Connected { tool_count } => ("✓", format!("{tool_count} tools")),
131 McpServerStatus::Failed { error } => ("✗", error.clone()),
132 McpServerStatus::Connecting => ("…", "connecting".to_string()),
133 McpServerStatus::Authenticating => ("…", "authenticating".to_string()),
134 McpServerStatus::NeedsOAuth => ("⚡", "needs authentication".to_string()),
135 };
136 let prefix = if indented { " " } else { "" };
137 let text = format!("{prefix}{} {indicator} {detail}", entry.name);
138 match &entry.status {
139 McpServerStatus::Connected { .. } | McpServerStatus::Connecting => {
140 if selected {
141 Line::with_style(text, context.theme.selected_row_style())
142 } else {
143 Line::new(text)
144 }
145 }
146 McpServerStatus::Failed { .. } => {
147 if selected {
148 Line::with_style(text, context.theme.selected_row_style_with_fg(context.theme.error()))
149 } else {
150 Line::styled(text, context.theme.error())
151 }
152 }
153 McpServerStatus::Authenticating | McpServerStatus::NeedsOAuth => {
154 if selected {
155 Line::with_style(text, context.theme.selected_row_style_with_fg(context.theme.warning()))
156 } else {
157 Line::styled(text, context.theme.warning())
158 }
159 }
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use acp_utils::notifications::McpServerAuthCapability;
167
168 fn sample_entries() -> Vec<McpServerStatusEntry> {
169 vec![
170 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
171 McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
172 .with_auth_capability(McpServerAuthCapability::OAuth),
173 McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
174 ]
175 }
176
177 fn mixed_entries() -> Vec<McpServerStatusEntry> {
178 vec![
179 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
180 McpServerStatusEntry::new("math", McpServerStatus::Connected { tool_count: 3 }).with_proxied(true),
181 McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
182 .with_auth_capability(McpServerAuthCapability::OAuth)
183 .with_proxied(true),
184 ]
185 }
186
187 fn key(code: tui::KeyCode) -> Event {
188 Event::Key(tui::KeyEvent::new(code, tui::KeyModifiers::NONE))
189 }
190
191 #[test]
192 fn renders_flat_entries_when_no_proxy_exists() {
193 let mut overlay = ServerStatusOverlay::new(sample_entries());
194 let ctx = ViewContext::new((80, 24));
195 let frame = overlay.render(&ctx);
196
197 assert_eq!(frame.lines().len(), 3);
198 assert!(frame.lines()[0].plain_text().contains("github"));
199 assert!(frame.lines()[0].plain_text().contains("✓"));
200 assert!(frame.lines()[0].plain_text().contains("5 tools"));
201 assert!(frame.lines()[1].plain_text().contains("linear"));
202 assert!(frame.lines()[1].plain_text().contains("⚡"));
203 assert!(frame.lines()[2].plain_text().contains("slack"));
204 assert!(frame.lines()[2].plain_text().contains("✗"));
205 assert!(frame.lines()[2].plain_text().contains("connection timeout"));
206 assert!(!frame.lines().iter().any(|line| line.plain_text().contains("Direct")));
207 }
208
209 #[test]
210 fn renders_direct_and_proxied_sections_when_proxy_exists() {
211 let mut overlay = ServerStatusOverlay::new(mixed_entries());
212 let ctx = ViewContext::new((80, 24));
213 let text: Vec<_> = overlay.render(&ctx).lines().iter().map(tui::Line::plain_text).collect();
214
215 assert_eq!(text[0].trim(), "Direct");
216 assert!(text[1].contains(" github ✓ 5 tools"));
217 assert!(text[2].trim().is_empty());
218 assert_eq!(text[3].trim(), "Proxied");
219 assert!(text[4].contains(" math ✓ 3 tools"));
220 assert!(text[5].contains(" linear ⚡ needs authentication"));
221 assert!(!text.join("\n").contains("proxy ✓ 1 tool"));
222 }
223
224 #[tokio::test]
225 async fn navigation_skips_headers_and_spacers() {
226 let mut overlay = ServerStatusOverlay::new(mixed_entries());
227
228 assert_eq!(overlay.list.selected_index(), 1);
229 overlay.on_event(&key(tui::KeyCode::Down)).await;
230 assert_eq!(overlay.list.selected_index(), 4);
231 overlay.on_event(&key(tui::KeyCode::Up)).await;
232 assert_eq!(overlay.list.selected_index(), 1);
233 overlay.on_event(&key(tui::KeyCode::Up)).await;
234 assert_eq!(overlay.list.selected_index(), 5);
235 }
236
237 #[tokio::test]
238 async fn enter_on_proxied_oauth_server_emits_nested_server_name() {
239 let mut overlay = ServerStatusOverlay::new(mixed_entries());
240 overlay.list.set_selected(5);
241
242 let outcome = overlay.on_event(&key(tui::KeyCode::Enter)).await;
243 let messages = outcome.unwrap();
244 match messages.as_slice() {
245 [ServerStatusMessage::Authenticate(name)] => assert_eq!(name, "linear"),
246 _ => panic!("Expected Authenticate message"),
247 }
248 }
249
250 #[tokio::test]
251 async fn enter_on_connected_without_auth_is_noop() {
252 let mut overlay = ServerStatusOverlay::new(sample_entries());
253
254 let outcome = overlay.on_event(&key(tui::KeyCode::Enter)).await;
255 assert!(outcome.unwrap().is_empty());
256 }
257
258 #[tokio::test]
259 async fn esc_closes_overlay() {
260 let mut overlay = ServerStatusOverlay::new(sample_entries());
261 let outcome = overlay.on_event(&key(tui::KeyCode::Esc)).await;
262 let messages = outcome.unwrap();
263 assert!(matches!(messages.as_slice(), [ServerStatusMessage::Close]));
264 }
265
266 #[test]
267 fn empty_entries_shows_placeholder() {
268 let mut overlay = ServerStatusOverlay::new(vec![]);
269 let ctx = ViewContext::new((80, 24));
270 let frame = overlay.render(&ctx);
271 assert_eq!(frame.lines().len(), 1);
272 assert!(frame.lines()[0].plain_text().contains("no MCP servers configured"));
273 }
274
275 #[test]
276 fn update_entries_preserves_selection_by_server_name() {
277 let mut overlay = ServerStatusOverlay::new(mixed_entries());
278 overlay.list.set_selected(5);
279
280 overlay.update_entries(vec![
281 McpServerStatusEntry::new("linear", McpServerStatus::Connected { tool_count: 7 }).with_proxied(true),
282 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 3 }),
283 ]);
284
285 let selected = match overlay.list.selected_item() {
286 Some(ServerStatusRow::Server { entry, .. }) => Some(entry.name.as_str()),
287 _ => None,
288 };
289 assert_eq!(selected, Some("linear"));
290 }
291
292 #[test]
293 fn update_entries_falls_back_to_first_selectable_row() {
294 let mut overlay = ServerStatusOverlay::new(mixed_entries());
295 overlay.list.set_selected(5);
296
297 overlay.update_entries(vec![McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 3 })]);
298
299 let selected = match overlay.list.selected_item() {
300 Some(ServerStatusRow::Server { entry, .. }) => Some(entry.name.as_str()),
301 _ => None,
302 };
303 assert_eq!(selected, Some("github"));
304 }
305}