Skip to main content

wisp/components/
server_status.rs

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}