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