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 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}