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 (deferred, model_visible): (Vec<_>, Vec<_>) = entries.into_iter().partition(|entry| entry.deferred_tools);
109
110 if deferred.is_empty() {
111 return model_visible.into_iter().map(|entry| ServerStatusRow::Server { entry, indented: false }).collect();
112 }
113
114 let mut rows = Vec::new();
115 if !model_visible.is_empty() {
116 rows.push(ServerStatusRow::Header("Model-visible".to_string()));
117 rows.extend(model_visible.into_iter().map(|entry| ServerStatusRow::Server { entry, indented: true }));
118 rows.push(ServerStatusRow::Spacer);
119 }
120 rows.push(ServerStatusRow::Header("Deferred".to_string()));
121 rows.extend(deferred.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_deferred_tools(true),
181 McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
182 .with_auth_capability(McpServerAuthCapability::OAuth)
183 .with_deferred_tools(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_deferred_tools_exist() {
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_model_visible_and_deferred_sections() {
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(), "Model-visible");
216 assert!(text[1].contains(" github ✓ 5 tools"));
217 assert!(text[2].trim().is_empty());
218 assert_eq!(text[3].trim(), "Deferred");
219 assert!(text[4].contains(" math ✓ 3 tools"));
220 assert!(text[5].contains(" linear ⚡ needs authentication"));
221 }
222
223 #[tokio::test]
224 async fn navigation_skips_headers_and_spacers() {
225 let mut overlay = ServerStatusOverlay::new(mixed_entries());
226
227 assert_eq!(overlay.list.selected_index(), 1);
228 overlay.on_event(&key(tui::KeyCode::Down)).await;
229 assert_eq!(overlay.list.selected_index(), 4);
230 overlay.on_event(&key(tui::KeyCode::Up)).await;
231 assert_eq!(overlay.list.selected_index(), 1);
232 overlay.on_event(&key(tui::KeyCode::Up)).await;
233 assert_eq!(overlay.list.selected_index(), 5);
234 }
235
236 #[tokio::test]
237 async fn enter_on_deferred_oauth_server_emits_server_name() {
238 let mut overlay = ServerStatusOverlay::new(mixed_entries());
239 overlay.list.set_selected(5);
240
241 let outcome = overlay.on_event(&key(tui::KeyCode::Enter)).await;
242 let messages = outcome.unwrap();
243 match messages.as_slice() {
244 [ServerStatusMessage::Authenticate(name)] => assert_eq!(name, "linear"),
245 _ => panic!("Expected Authenticate message"),
246 }
247 }
248
249 #[tokio::test]
250 async fn enter_on_connected_without_auth_is_noop() {
251 let mut overlay = ServerStatusOverlay::new(sample_entries());
252
253 let outcome = overlay.on_event(&key(tui::KeyCode::Enter)).await;
254 assert!(outcome.unwrap().is_empty());
255 }
256
257 #[tokio::test]
258 async fn esc_closes_overlay() {
259 let mut overlay = ServerStatusOverlay::new(sample_entries());
260 let outcome = overlay.on_event(&key(tui::KeyCode::Esc)).await;
261 let messages = outcome.unwrap();
262 assert!(matches!(messages.as_slice(), [ServerStatusMessage::Close]));
263 }
264
265 #[test]
266 fn empty_entries_shows_placeholder() {
267 let mut overlay = ServerStatusOverlay::new(vec![]);
268 let ctx = ViewContext::new((80, 24));
269 let frame = overlay.render(&ctx);
270 assert_eq!(frame.lines().len(), 1);
271 assert!(frame.lines()[0].plain_text().contains("no MCP servers configured"));
272 }
273
274 #[test]
275 fn update_entries_preserves_selection_by_server_name() {
276 let mut overlay = ServerStatusOverlay::new(mixed_entries());
277 overlay.list.set_selected(5);
278
279 overlay.update_entries(vec![
280 McpServerStatusEntry::new("linear", McpServerStatus::Connected { tool_count: 7 }).with_deferred_tools(true),
281 McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 3 }),
282 ]);
283
284 let selected = match overlay.list.selected_item() {
285 Some(ServerStatusRow::Server { entry, .. }) => Some(entry.name.as_str()),
286 _ => None,
287 };
288 assert_eq!(selected, Some("linear"));
289 }
290
291 #[test]
292 fn update_entries_falls_back_to_first_selectable_row() {
293 let mut overlay = ServerStatusOverlay::new(mixed_entries());
294 overlay.list.set_selected(5);
295
296 overlay.update_entries(vec![McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 3 })]);
297
298 let selected = match overlay.list.selected_item() {
299 Some(ServerStatusRow::Server { entry, .. }) => Some(entry.name.as_str()),
300 _ => None,
301 };
302 assert_eq!(selected, Some("github"));
303 }
304}