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