Skip to main content

sequel_mcp/gui/
window.rs

1//! The egui window: renders [`ViewState`] and forwards button clicks as
2//! [`GrantChoice`]s. Deliberately thin — every state transition lives in
3//! the unit-tested reducer (`super::app`) and the connection loop
4//! (`super::companion`).
5
6use super::SessionInfo;
7use super::app::{Status, ViewState, apply_event, apply_not_delivered};
8use super::companion::CompanionEvent;
9use crate::approval::GrantChoice;
10use std::time::{Duration, Instant};
11
12pub struct ApprovalsApp {
13    state: ViewState,
14    choices: tokio::sync::mpsc::Sender<GrantChoice>,
15    events: std::sync::mpsc::Receiver<CompanionEvent>,
16    /// Edit buffer for the pending snippet (egui TextEdit wants &mut
17    /// String); re-synced whenever the pending request id changes.
18    snippet_buf: String,
19    snippet_for: Option<String>,
20    sessions: Vec<SessionInfo>,
21    sessions_refreshed: Instant,
22    /// Smoke-test support: close the window after N frames.
23    smoke_remaining: Option<u32>,
24    frames_seen: u64,
25}
26
27impl ApprovalsApp {
28    pub fn new(
29        state: ViewState,
30        choices: tokio::sync::mpsc::Sender<GrantChoice>,
31        events: std::sync::mpsc::Receiver<CompanionEvent>,
32        smoke_frames: Option<u32>,
33    ) -> Self {
34        Self {
35            state,
36            choices,
37            events,
38            snippet_buf: String::new(),
39            snippet_for: None,
40            sessions: Vec::new(),
41            sessions_refreshed: Instant::now()
42                .checked_sub(Duration::from_secs(10))
43                .unwrap_or_else(Instant::now),
44            smoke_remaining: smoke_frames,
45            frames_seen: 0,
46        }
47    }
48
49    fn send_choice(&mut self, choice: GrantChoice) {
50        if let Some(p) = self.state.pending.as_mut() {
51            p.answering = Some(choice.clone());
52        }
53        // blocking_send is the sanctioned way to hand a value into the
54        // async companion loop from this non-async UI thread.
55        if self.choices.blocking_send(choice.clone()).is_err() {
56            apply_not_delivered(&mut self.state, choice);
57        }
58    }
59
60    fn render(&mut self, ui: &mut egui::Ui) {
61        ui.heading("sequel-mcp approvals");
62        ui.add_space(4.0);
63
64        // Status line.
65        let (color, label) = match &self.state.status {
66            Status::Connecting => (egui::Color32::YELLOW, "connecting…".to_string()),
67            Status::Waiting => (
68                egui::Color32::LIGHT_GREEN,
69                "waiting for approval requests".to_string(),
70            ),
71            Status::Disconnected(reason) => (
72                egui::Color32::LIGHT_RED,
73                format!("no server ({reason}) — retrying"),
74            ),
75        };
76        ui.horizontal(|ui| {
77            ui.label(egui::RichText::new("●").color(color).strong());
78            ui.label(label);
79        });
80        ui.label(
81            egui::RichText::new(format!("socket: {}", self.state.socket.display()))
82                .small()
83                .weak(),
84        );
85        let live = self.sessions.iter().filter(|s| s.alive).count();
86        ui.label(
87            egui::RichText::new(if live == 0 {
88                "servers live: 0 — start one with `sequel-mcp serve`".to_string()
89            } else {
90                format!(
91                    "servers live: {live} (pids {})",
92                    self.sessions
93                        .iter()
94                        .filter(|s| s.alive)
95                        .map(|s| s.pid.to_string())
96                        .collect::<Vec<_>>()
97                        .join(", ")
98                )
99            })
100            .small()
101            .weak(),
102        );
103        ui.add_space(6.0);
104
105        // Pending request card, or the idle placeholder.
106        let mut action: Option<GrantChoice> = None;
107        if let Some(p) = self.state.pending.as_ref() {
108            egui::Frame::group(ui.style()).show(ui, |ui| {
109                ui.set_width(ui.available_width());
110                ui.horizontal(|ui| {
111                    ui.label(
112                        egui::RichText::new(format!(" {} ", p.request.category.to_uppercase()))
113                            .background_color(category_color(&p.request.category))
114                            .color(egui::Color32::WHITE)
115                            .strong(),
116                    );
117                    ui.label(egui::RichText::new("confirmation required").strong());
118                });
119                ui.add_space(6.0);
120                egui::Grid::new("request-fields")
121                    .num_columns(2)
122                    .spacing([12.0, 4.0])
123                    .show(ui, |ui| {
124                        ui.label("connection");
125                        ui.strong(&p.request.connection);
126                        ui.end_row();
127                        if let Some(db) = &p.request.database {
128                            ui.label("database");
129                            ui.label(db);
130                            ui.end_row();
131                        }
132                        if !p.request.tables.is_empty() {
133                            ui.label("tables");
134                            ui.label(p.request.tables.join(", "));
135                            ui.end_row();
136                        }
137                    });
138                ui.add_space(4.0);
139                egui::Frame::default()
140                    .fill(ui.visuals().code_bg_color)
141                    .inner_margin(egui::Margin::symmetric(8, 8))
142                    .corner_radius(egui::CornerRadius::same(4))
143                    .show(ui, |ui| {
144                        let rows = 2.max(snippet_rows(&self.snippet_buf)).min(12);
145                        ui.add(
146                            egui::TextEdit::multiline(&mut self.snippet_buf)
147                                .font(egui::TextStyle::Monospace)
148                                .desired_rows(rows)
149                                .desired_width(f32::INFINITY)
150                                .interactive(false),
151                        );
152                    });
153                let secs = p.arrived.elapsed().as_secs();
154                if secs >= 55 {
155                    ui.label(
156                        egui::RichText::new(format!(
157                            "waiting {secs}s — server deadline is 60s, this may already be expired"
158                        ))
159                        .color(egui::Color32::from_rgb(0xff, 0xb3, 0x00)),
160                    );
161                } else {
162                    ui.label(egui::RichText::new(format!("waiting {secs}s")).weak());
163                }
164                ui.add_space(4.0);
165                match &p.answering {
166                    Some(choice) => {
167                        ui.label(format!("delivering {}…", choice_word(choice)));
168                    }
169                    None => {
170                        ui.horizontal(|ui| {
171                            if ui
172                                .add_sized([160.0, 32.0], egui::Button::new("Approve once"))
173                                .clicked()
174                            {
175                                action = Some(GrantChoice::Once);
176                            }
177                            if ui
178                                .add_sized([190.0, 32.0], egui::Button::new("Approve for session"))
179                                .clicked()
180                            {
181                                action = Some(GrantChoice::Session);
182                            }
183                            if ui
184                                .add_sized(
185                                    [120.0, 32.0],
186                                    egui::Button::new(
187                                        egui::RichText::new("Decline").color(egui::Color32::WHITE),
188                                    )
189                                    .fill(egui::Color32::from_rgb(0xb7, 0x1c, 0x1c)),
190                                )
191                                .clicked()
192                            {
193                                action = Some(GrantChoice::Decline);
194                            }
195                        });
196                    }
197                }
198            });
199        } else {
200            ui.label(
201                egui::RichText::new(
202                    "No pending request. When the server needs a confirmation it appears here.",
203                )
204                .weak(),
205            );
206        }
207
208        // History.
209        ui.add_space(8.0);
210        ui.separator();
211        ui.label(egui::RichText::new("Recent answers").strong());
212        if self.state.history.is_empty() {
213            ui.weak("nothing answered yet");
214        } else {
215            egui::ScrollArea::vertical()
216                .max_height(160.0)
217                .show(ui, |ui| {
218                    egui::Grid::new("history")
219                        .num_columns(4)
220                        .spacing([12.0, 3.0])
221                        .show(ui, |ui| {
222                            for h in self.state.history.iter().take(50) {
223                                ui.monospace(&h.ts);
224                                ui.label(
225                                    egui::RichText::new(&h.choice)
226                                        .color(choice_color(h.choice.as_str())),
227                                );
228                                ui.label(&h.connection);
229                                ui.weak(&h.result);
230                                ui.end_row();
231                            }
232                        });
233                });
234        }
235        ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
236            ui.weak(format!(
237                "answered this session: {} · frames: {}",
238                self.state.answered_total, self.frames_seen
239            ));
240        });
241
242        if let Some(choice) = action {
243            self.send_choice(choice);
244        }
245    }
246}
247
248impl eframe::App for ApprovalsApp {
249    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
250        self.frames_seen += 1;
251        while let Ok(event) = self.events.try_recv() {
252            apply_event(&mut self.state, event);
253        }
254        if self.sessions_refreshed.elapsed() >= Duration::from_secs(5) {
255            self.sessions = super::live_sessions();
256            self.sessions_refreshed = Instant::now();
257        }
258        // Re-sync the snippet edit buffer when the pending request changes.
259        let pending_id = self.state.pending.as_ref().map(|p| p.request.id.clone());
260        if pending_id != self.snippet_for {
261            self.snippet_for = pending_id;
262            self.snippet_buf = self
263                .state
264                .pending
265                .as_ref()
266                .map(|p| p.request.snippet.clone())
267                .unwrap_or_default();
268        }
269
270        self.render(ui);
271
272        // Repaint cadence: often enough for the pending countdown, rarely
273        // otherwise (the companion queues events; egui polls them).
274        let busy = self.state.pending.is_some();
275        ui.ctx().request_repaint_after(if busy {
276            Duration::from_millis(250)
277        } else {
278            Duration::from_secs(1)
279        });
280
281        if let Some(n) = &mut self.smoke_remaining {
282            *n = n.saturating_sub(1);
283            if *n == 0 {
284                ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
285            }
286        }
287    }
288}
289
290fn choice_word(choice: &GrantChoice) -> &'static str {
291    match choice {
292        GrantChoice::Once => "once",
293        GrantChoice::Session => "session",
294        GrantChoice::Decline => "decline",
295    }
296}
297
298fn choice_color(word: &str) -> egui::Color32 {
299    match word {
300        "once" => egui::Color32::LIGHT_GREEN,
301        "session" => egui::Color32::LIGHT_BLUE,
302        "decline" => egui::Color32::LIGHT_RED,
303        _ => egui::Color32::GRAY,
304    }
305}
306
307fn category_color(category: &str) -> egui::Color32 {
308    let c = category.to_ascii_lowercase();
309    if c.contains("ddl") || c.contains("admin") || c.contains("drop") {
310        egui::Color32::from_rgb(0xc6, 0x28, 0x28)
311    } else if c.contains("write")
312        || c.contains("insert")
313        || c.contains("update")
314        || c.contains("delete")
315    {
316        egui::Color32::from_rgb(0xef, 0x6c, 0x00)
317    } else {
318        egui::Color32::from_rgb(0x37, 0x47, 0x4f)
319    }
320}
321
322fn snippet_rows(snippet: &str) -> usize {
323    snippet.lines().count()
324}