wisp/components/
server_status.rs1use 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); 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 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}