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
4struct ServerItem(McpServerStatusEntry);
5
6impl SelectItem for ServerItem {
7    fn render_item(&self, selected: bool, context: &ViewContext) -> Line {
8        let (indicator, detail) = match &self.0.status {
9            McpServerStatus::Connected { tool_count } if self.0.can_authenticate() => {
10                ("✓", format!("{tool_count} tools, authenticated"))
11            }
12            McpServerStatus::Connected { tool_count } => ("✓", format!("{tool_count} tools")),
13            McpServerStatus::Failed { error } => ("✗", error.clone()),
14            McpServerStatus::NeedsOAuth => ("⚡", "needs authentication".to_string()),
15        };
16        let text = format!("{}  {indicator} {detail}", self.0.name);
17        match &self.0.status {
18            McpServerStatus::Connected { .. } => {
19                if selected {
20                    Line::with_style(text, context.theme.selected_row_style())
21                } else {
22                    Line::new(text)
23                }
24            }
25            McpServerStatus::Failed { .. } => {
26                if selected {
27                    Line::with_style(text, context.theme.selected_row_style_with_fg(context.theme.error()))
28                } else {
29                    Line::styled(text, context.theme.error())
30                }
31            }
32            McpServerStatus::NeedsOAuth => {
33                if selected {
34                    Line::with_style(text, context.theme.selected_row_style_with_fg(context.theme.warning()))
35                } else {
36                    Line::styled(text, context.theme.warning())
37                }
38            }
39        }
40    }
41}
42
43pub struct ServerStatusOverlay {
44    list: SelectList<ServerItem>,
45}
46
47pub enum ServerStatusMessage {
48    Close,
49    Authenticate(String),
50}
51
52impl Component for ServerStatusOverlay {
53    type Message = ServerStatusMessage;
54
55    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
56        let outcome = self.list.on_event(event).await;
57        match outcome.as_deref() {
58            Some([SelectListMessage::Close]) => Some(vec![ServerStatusMessage::Close]),
59            Some([SelectListMessage::Select(_)]) => {
60                if let Some(item) = self.list.selected_item()
61                    && item.0.can_authenticate()
62                {
63                    return Some(vec![ServerStatusMessage::Authenticate(item.0.name.clone())]);
64                }
65                Some(vec![])
66            }
67            _ => outcome.map(|_| vec![]),
68        }
69    }
70
71    fn render(&mut self, context: &ViewContext) -> Frame {
72        self.list.render(context)
73    }
74}
75
76pub fn server_status_summary(statuses: &[McpServerStatusEntry]) -> String {
77    if statuses.is_empty() {
78        return "none".to_string();
79    }
80    let (mut c, mut n, mut f) = (0usize, 0usize, 0usize);
81    for s in statuses {
82        match &s.status {
83            McpServerStatus::Connected { .. } => c += 1,
84            McpServerStatus::NeedsOAuth => n += 1,
85            McpServerStatus::Failed { .. } => f += 1,
86        }
87    }
88    [(c, "connected"), (n, "needs auth"), (f, "failed")]
89        .iter()
90        .filter(|(count, _)| *count > 0)
91        .map(|(count, label)| format!("{count} {label}"))
92        .collect::<Vec<_>>()
93        .join(", ")
94}
95
96impl ServerStatusOverlay {
97    pub fn new(entries: Vec<McpServerStatusEntry>) -> Self {
98        let items: Vec<ServerItem> = entries.into_iter().map(ServerItem).collect();
99        Self { list: SelectList::new(items, "no MCP servers configured") }
100    }
101
102    pub fn update_entries(&mut self, entries: Vec<McpServerStatusEntry>) {
103        let prev_index = self.list.selected_index();
104        let items: Vec<ServerItem> = entries.into_iter().map(ServerItem).collect();
105        self.list.set_items(items);
106        let max = self.list.len().saturating_sub(1);
107        self.list.set_selected(prev_index.min(max));
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use acp_utils::notifications::McpServerAuthCapability;
115
116    fn sample_entries() -> Vec<McpServerStatusEntry> {
117        vec![
118            McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 }),
119            McpServerStatusEntry::new("linear", McpServerStatus::NeedsOAuth)
120                .with_auth_capability(McpServerAuthCapability::OAuth),
121            McpServerStatusEntry::new("slack", McpServerStatus::Failed { error: "connection timeout".to_string() }),
122        ]
123    }
124
125    #[test]
126    fn renders_all_entries_with_status_indicators() {
127        let mut overlay = ServerStatusOverlay::new(sample_entries());
128        let ctx = ViewContext::new((80, 24));
129        let frame = overlay.render(&ctx);
130
131        assert_eq!(frame.lines().len(), 3);
132        let text0 = frame.lines()[0].plain_text();
133        assert!(text0.contains("github"), "should contain server name");
134        assert!(text0.contains("✓"), "connected should show checkmark");
135        assert!(text0.contains("5 tools"), "should show tool count");
136
137        let text1 = frame.lines()[1].plain_text();
138        assert!(text1.contains("linear"), "should contain server name");
139        assert!(text1.contains("⚡"), "needs auth should show bolt");
140
141        let text2 = frame.lines()[2].plain_text();
142        assert!(text2.contains("slack"), "should contain server name");
143        assert!(text2.contains("✗"), "failed should show X");
144        assert!(text2.contains("connection timeout"), "should show error");
145    }
146
147    #[test]
148    fn selected_entry_has_pointer() {
149        let mut overlay = ServerStatusOverlay::new(sample_entries());
150        let ctx = ViewContext::new((80, 24));
151        let frame = overlay.render(&ctx);
152
153        assert!(frame.lines()[0].plain_text().starts_with("  "));
154        assert!(frame.lines()[1].plain_text().starts_with("  "));
155    }
156
157    #[tokio::test]
158    async fn navigation_wraps_around() {
159        let mut overlay = ServerStatusOverlay::new(sample_entries());
160
161        overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Up, tui::KeyModifiers::NONE))).await;
162        assert_eq!(overlay.list.selected_index(), 2);
163
164        overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Down, tui::KeyModifiers::NONE))).await;
165        assert_eq!(overlay.list.selected_index(), 0);
166    }
167
168    #[tokio::test]
169    async fn enter_on_needs_oauth_emits_authenticate() {
170        let mut overlay = ServerStatusOverlay::new(sample_entries());
171        overlay.list.set_selected(1); // linear - NeedsOAuth
172
173        let outcome =
174            overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
175        let messages = outcome.unwrap();
176        match messages.as_slice() {
177            [ServerStatusMessage::Authenticate(name)] => assert_eq!(name, "linear"),
178            _ => panic!("Expected Authenticate message"),
179        }
180    }
181
182    #[tokio::test]
183    async fn enter_on_connected_without_auth_is_noop() {
184        let mut overlay = ServerStatusOverlay::new(sample_entries());
185        // index 0 = github (Connected)
186
187        let outcome =
188            overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
189        assert!(outcome.unwrap().is_empty());
190    }
191
192    #[tokio::test]
193    async fn enter_on_connected_oauth_server_emits_authenticate_for_reauth() {
194        let mut overlay = ServerStatusOverlay::new(vec![
195            McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 5 })
196                .with_auth_capability(McpServerAuthCapability::OAuth),
197        ]);
198
199        let outcome =
200            overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Enter, tui::KeyModifiers::NONE))).await;
201        let messages = outcome.unwrap();
202        match messages.as_slice() {
203            [ServerStatusMessage::Authenticate(name)] => assert_eq!(name, "github"),
204            _ => panic!("Expected Authenticate message"),
205        }
206    }
207
208    #[tokio::test]
209    async fn esc_closes_overlay() {
210        let mut overlay = ServerStatusOverlay::new(sample_entries());
211        let outcome =
212            overlay.on_event(&Event::Key(tui::KeyEvent::new(tui::KeyCode::Esc, tui::KeyModifiers::NONE))).await;
213        let messages = outcome.unwrap();
214        assert!(matches!(messages.as_slice(), [ServerStatusMessage::Close]));
215    }
216
217    #[test]
218    fn empty_entries_shows_placeholder() {
219        let mut overlay = ServerStatusOverlay::new(vec![]);
220        let ctx = ViewContext::new((80, 24));
221        let frame = overlay.render(&ctx);
222        assert_eq!(frame.lines().len(), 1);
223        assert!(frame.lines()[0].plain_text().contains("no MCP servers configured"));
224    }
225
226    #[test]
227    fn update_entries_clamps_index() {
228        let mut overlay = ServerStatusOverlay::new(sample_entries());
229        overlay.list.set_selected(2);
230
231        overlay.update_entries(vec![McpServerStatusEntry::new("github", McpServerStatus::Connected { tool_count: 3 })]);
232        assert_eq!(overlay.list.selected_index(), 0);
233    }
234}