1use 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 snippet_buf: String,
19 snippet_for: Option<String>,
20 sessions: Vec<SessionInfo>,
21 sessions_refreshed: Instant,
22 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 if self.choices.try_send(answer).is_err() {
66 apply_not_delivered(&mut self.state, choice);
67 }
68 if !self.watch {
69 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 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 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 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 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 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 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}