1use 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 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}
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 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 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 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 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 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 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}