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::{CompanionChoice, CompanionEvent};
9use crate::approval::GrantChoice;
10use std::time::{Duration, Instant};
11
12pub struct ApprovalsApp {
13    state: ViewState,
14    choices: tokio::sync::mpsc::Sender<CompanionChoice>,
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    watch: bool,
26}
27
28impl ApprovalsApp {
29    pub fn new(
30        state: ViewState,
31        choices: tokio::sync::mpsc::Sender<CompanionChoice>,
32        events: std::sync::mpsc::Receiver<CompanionEvent>,
33        smoke_frames: Option<u32>,
34        watch: bool,
35    ) -> Self {
36        Self {
37            state,
38            choices,
39            events,
40            snippet_buf: String::new(),
41            snippet_for: None,
42            sessions: Vec::new(),
43            sessions_refreshed: Instant::now()
44                .checked_sub(Duration::from_secs(10))
45                .unwrap_or_else(Instant::now),
46            smoke_remaining: smoke_frames,
47            frames_seen: 0,
48            watch,
49        }
50    }
51
52    fn send_choice(&mut self, ctx: &egui::Context, choice: GrantChoice) {
53        let Some(p) = self.state.pending.as_mut() else {
54            return;
55        };
56        if p.answering.is_some() {
57            return;
58        }
59        p.answering = Some(choice.clone());
60        let answer = CompanionChoice {
61            id: p.request.id.clone(),
62            choice: choice.clone(),
63        };
64        // Never block the UI or let repeated clicks queue another answer.
65        if self.choices.try_send(answer).is_err() {
66            apply_not_delivered(&mut self.state, choice);
67        }
68        if !self.watch {
69            // Disappear immediately, but keep processing events until the
70            // server acknowledges delivery (or reports failure).
71            ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
72            ctx.request_repaint();
73        }
74    }
75
76    fn render(&mut self, ui: &mut egui::Ui) {
77        ui.heading("sequel-mcp approvals");
78        ui.add_space(4.0);
79
80        // Connection diagnostics belong to the optional monitor.
81        if self.watch {
82            let (color, label) = match &self.state.status {
83                Status::Connecting => (egui::Color32::YELLOW, "connecting…".to_string()),
84                Status::Waiting => (
85                    egui::Color32::LIGHT_GREEN,
86                    "waiting for approval requests".to_string(),
87                ),
88                Status::Disconnected(reason) => (
89                    egui::Color32::LIGHT_RED,
90                    format!("no server ({reason}) — retrying"),
91                ),
92            };
93            ui.horizontal(|ui| {
94                ui.label(egui::RichText::new("●").color(color).strong());
95                ui.label(label);
96            });
97            ui.label(
98                egui::RichText::new(format!("socket: {}", self.state.socket.display()))
99                    .small()
100                    .weak(),
101            );
102            let live = self.sessions.iter().filter(|s| s.alive).count();
103            ui.label(
104                egui::RichText::new(if live == 0 {
105                    "servers live: 0 — start one with `sequel-mcp serve`".to_string()
106                } else {
107                    format!(
108                        "servers live: {live} (pids {})",
109                        self.sessions
110                            .iter()
111                            .filter(|s| s.alive)
112                            .map(|s| s.pid.to_string())
113                            .collect::<Vec<_>>()
114                            .join(", ")
115                    )
116                })
117                .small()
118                .weak(),
119            );
120            ui.add_space(6.0);
121        }
122
123        // Pending request card, or the idle placeholder.
124        let mut action: Option<GrantChoice> = None;
125        if let Some(p) = self.state.pending.as_ref() {
126            egui::Frame::group(ui.style()).show(ui, |ui| {
127                ui.set_width(ui.available_width());
128                ui.horizontal(|ui| {
129                    ui.label(
130                        egui::RichText::new(format!(" {} ", p.request.category.to_uppercase()))
131                            .background_color(category_color(&p.request.category))
132                            .color(egui::Color32::WHITE)
133                            .strong(),
134                    );
135                    ui.label(egui::RichText::new("confirmation required").strong());
136                });
137                ui.add_space(6.0);
138                egui::Grid::new("request-fields")
139                    .num_columns(2)
140                    .spacing([12.0, 4.0])
141                    .show(ui, |ui| {
142                        ui.label("connection");
143                        ui.strong(&p.request.connection);
144                        ui.end_row();
145                        if let Some(db) = &p.request.database {
146                            ui.label("database");
147                            ui.label(db);
148                            ui.end_row();
149                        }
150                        if !p.request.tables.is_empty() {
151                            ui.label("tables");
152                            ui.label(p.request.tables.join(", "));
153                            ui.end_row();
154                        }
155                    });
156                ui.add_space(4.0);
157                egui::Frame::default()
158                    .fill(ui.visuals().code_bg_color)
159                    .inner_margin(egui::Margin::symmetric(8, 8))
160                    .corner_radius(egui::CornerRadius::same(4))
161                    .show(ui, |ui| {
162                        egui::ScrollArea::vertical()
163                            .max_height(120.0)
164                            .show(ui, |ui| {
165                                let rows = 2.max(snippet_rows(&self.snippet_buf)).min(12);
166                                ui.add(
167                                    egui::TextEdit::multiline(&mut self.snippet_buf)
168                                        .font(egui::TextStyle::Monospace)
169                                        .desired_rows(rows)
170                                        .desired_width(f32::INFINITY)
171                                        .interactive(false),
172                                );
173                            });
174                    });
175                let secs = p.arrived.elapsed().as_secs();
176                if secs >= 55 {
177                    ui.label(
178                        egui::RichText::new(format!(
179                            "waiting {secs}s — server deadline is 60s, this may already be expired"
180                        ))
181                        .color(egui::Color32::from_rgb(0xff, 0xb3, 0x00)),
182                    );
183                } else {
184                    ui.label(egui::RichText::new(format!("waiting {secs}s")).weak());
185                }
186                ui.add_space(4.0);
187                match &p.answering {
188                    Some(choice) => {
189                        ui.label(format!("delivering {}…", choice_word(choice)));
190                    }
191                    None => {
192                        ui.horizontal(|ui| {
193                            if ui
194                                .add_sized([160.0, 32.0], egui::Button::new("Approve once"))
195                                .clicked()
196                            {
197                                action = Some(GrantChoice::Once);
198                            }
199                            if ui
200                                .add_sized([190.0, 32.0], egui::Button::new("Approve for session"))
201                                .clicked()
202                            {
203                                action = Some(GrantChoice::Session);
204                            }
205                            if ui
206                                .add_sized(
207                                    [120.0, 32.0],
208                                    egui::Button::new(
209                                        egui::RichText::new("Decline").color(egui::Color32::WHITE),
210                                    )
211                                    .fill(egui::Color32::from_rgb(0xb7, 0x1c, 0x1c)),
212                                )
213                                .clicked()
214                            {
215                                action = Some(GrantChoice::Decline);
216                            }
217                        });
218                    }
219                }
220            });
221        } else {
222            ui.label(
223                egui::RichText::new(
224                    "No pending request. When the server needs a confirmation it appears here.",
225                )
226                .weak(),
227            );
228        }
229
230        if self.watch {
231            // History.
232            ui.add_space(8.0);
233            ui.separator();
234            ui.label(egui::RichText::new("Recent answers").strong());
235            if self.state.history.is_empty() {
236                ui.weak("nothing answered yet");
237            } else {
238                egui::ScrollArea::vertical()
239                    .max_height(160.0)
240                    .show(ui, |ui| {
241                        egui::Grid::new("history")
242                            .num_columns(4)
243                            .spacing([12.0, 3.0])
244                            .show(ui, |ui| {
245                                for h in self.state.history.iter().take(50) {
246                                    ui.monospace(&h.ts);
247                                    ui.label(
248                                        egui::RichText::new(&h.choice)
249                                            .color(choice_color(h.choice.as_str())),
250                                    );
251                                    ui.label(&h.connection);
252                                    ui.weak(&h.result);
253                                    ui.end_row();
254                                }
255                            });
256                    });
257            }
258            ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
259                ui.weak(format!(
260                    "answered this session: {} · frames: {}",
261                    self.state.answered_total, self.frames_seen
262                ));
263            });
264        }
265
266        if let Some(choice) = action {
267            self.send_choice(ui.ctx(), choice);
268        }
269    }
270}
271
272impl eframe::App for ApprovalsApp {
273    fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
274        self.process_events(ctx);
275    }
276
277    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
278        self.frames_seen += 1;
279        self.render(ui);
280        if self.state.pending.is_some() {
281            ui.ctx().request_repaint_after(Duration::from_millis(250));
282        } else if self.watch || self.smoke_remaining.is_some() {
283            ui.ctx().request_repaint_after(Duration::from_secs(1));
284        }
285        if let Some(n) = &mut self.smoke_remaining {
286            *n = n.saturating_sub(1);
287            if *n == 0 {
288                ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
289            }
290        }
291    }
292}
293
294impl ApprovalsApp {
295    /// eframe invokes logic even while hidden; acknowledgements must not
296    /// depend on rendering another visible frame.
297    fn process_events(&mut self, ctx: &egui::Context) {
298        while let Ok(event) = self.events.try_recv() {
299            apply_event(&mut self.state, event);
300        }
301        if !self.watch && self.state.finished {
302            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
303            return;
304        }
305        if self.watch && self.sessions_refreshed.elapsed() >= Duration::from_secs(5) {
306            self.sessions = super::live_sessions();
307            self.sessions_refreshed = Instant::now();
308        }
309        // Re-sync the snippet edit buffer when the pending request changes.
310        let pending_id = self.state.pending.as_ref().map(|p| p.request.id.clone());
311        if pending_id != self.snippet_for {
312            if pending_id.is_some() {
313                ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
314                ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
315                ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
316            }
317            self.snippet_for = pending_id;
318            self.snippet_buf = self
319                .state
320                .pending
321                .as_ref()
322                .map(|p| p.request.snippet.clone())
323                .unwrap_or_default();
324        }
325    }
326}
327
328fn choice_word(choice: &GrantChoice) -> &'static str {
329    match choice {
330        GrantChoice::Once => "once",
331        GrantChoice::Session => "session",
332        GrantChoice::Decline => "decline",
333    }
334}
335
336fn choice_color(word: &str) -> egui::Color32 {
337    match word {
338        "once" => egui::Color32::LIGHT_GREEN,
339        "session" => egui::Color32::LIGHT_BLUE,
340        "decline" => egui::Color32::LIGHT_RED,
341        _ => egui::Color32::GRAY,
342    }
343}
344
345fn category_color(category: &str) -> egui::Color32 {
346    let c = category.to_ascii_lowercase();
347    if c.contains("ddl") || c.contains("admin") || c.contains("drop") {
348        egui::Color32::from_rgb(0xc6, 0x28, 0x28)
349    } else if c.contains("write")
350        || c.contains("insert")
351        || c.contains("update")
352        || c.contains("delete")
353    {
354        egui::Color32::from_rgb(0xef, 0x6c, 0x00)
355    } else {
356        egui::Color32::from_rgb(0x37, 0x47, 0x4f)
357    }
358}
359
360fn snippet_rows(snippet: &str) -> usize {
361    snippet.lines().count()
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::approval::ipc::ApprovalRequest;
368
369    fn setup(
370        watch: bool,
371    ) -> (
372        ApprovalsApp,
373        std::sync::mpsc::Sender<CompanionEvent>,
374        tokio::sync::mpsc::Receiver<CompanionChoice>,
375    ) {
376        let (events, rx) = std::sync::mpsc::channel();
377        let (choices, answers) = tokio::sync::mpsc::channel(8);
378        let mut app = ApprovalsApp::new(
379            ViewState::new("/tmp/approval.sock".into()),
380            choices,
381            rx,
382            None,
383            watch,
384        );
385        // This unit test does not read the user's runtime registry.
386        app.sessions_refreshed = Instant::now();
387        (app, events, answers)
388    }
389
390    fn request() -> CompanionEvent {
391        CompanionEvent::Request {
392            request: ApprovalRequest {
393                id: "r1".into(),
394                category: "write".into(),
395                connection: "local-dev".into(),
396                database: Some("app".into()),
397                tables: vec!["app.items".into()],
398                snippet: "UPDATE items SET id = 2".into(),
399            },
400        }
401    }
402
403    fn commands(ctx: &egui::Context) -> Vec<egui::ViewportCommand> {
404        ctx.run_logic(&egui::RawInput::default(), |_| {})
405            .viewport_commands
406            .remove(&egui::ViewportId::ROOT)
407            .unwrap_or_default()
408    }
409
410    #[test]
411    fn prompt_shows_focuses_hides_on_each_choice_and_closes_after_ack() {
412        for choice in [
413            GrantChoice::Once,
414            GrantChoice::Session,
415            GrantChoice::Decline,
416        ] {
417            let ctx = egui::Context::default();
418            let (mut app, events, mut answers) = setup(false);
419            events.send(request()).unwrap();
420            app.process_events(&ctx);
421            let cmds = commands(&ctx);
422            assert!(cmds.contains(&egui::ViewportCommand::Visible(true)));
423            assert!(cmds.contains(&egui::ViewportCommand::Focus));
424            app.send_choice(&ctx, choice.clone());
425            assert!(commands(&ctx).contains(&egui::ViewportCommand::Visible(false)));
426            let answer = answers.try_recv().unwrap();
427            assert_eq!(answer.id, "r1");
428            assert_eq!(answer.choice, choice);
429            app.send_choice(&ctx, choice.clone());
430            assert!(
431                answers.try_recv().is_err(),
432                "double clicks must not queue another choice"
433            );
434            events
435                .send(CompanionEvent::Acked {
436                    id: "r1".into(),
437                    choice,
438                })
439                .unwrap();
440            app.process_events(&ctx);
441            assert!(commands(&ctx).contains(&egui::ViewportCommand::Close));
442        }
443    }
444
445    #[test]
446    fn prompt_closes_without_an_answer_on_expiry_disconnect_or_empty() {
447        for terminal in [
448            CompanionEvent::Stale { id: "r1".into() },
449            CompanionEvent::Disconnected {
450                reason: "gone".into(),
451            },
452            CompanionEvent::Empty,
453        ] {
454            let ctx = egui::Context::default();
455            let (mut app, events, mut answers) = setup(false);
456            events.send(request()).unwrap();
457            app.process_events(&ctx);
458            commands(&ctx);
459            events.send(terminal).unwrap();
460            app.process_events(&ctx);
461            assert!(commands(&ctx).contains(&egui::ViewportCommand::Close));
462            assert_eq!(app.state.answered_total, 0);
463            assert!(answers.try_recv().is_err());
464        }
465    }
466
467    #[test]
468    fn watch_mode_stays_open_after_answer() {
469        let ctx = egui::Context::default();
470        let (mut app, events, _) = setup(true);
471        events.send(request()).unwrap();
472        app.process_events(&ctx);
473        commands(&ctx);
474        events
475            .send(CompanionEvent::Acked {
476                id: "r1".into(),
477                choice: GrantChoice::Once,
478            })
479            .unwrap();
480        app.process_events(&ctx);
481        assert!(!commands(&ctx).contains(&egui::ViewportCommand::Close));
482        assert_eq!(app.state.answered_total, 1);
483    }
484}