use super::SessionInfo;
use super::app::{Status, ViewState, apply_event, apply_not_delivered};
use super::companion::{CompanionChoice, CompanionEvent};
use crate::approval::GrantChoice;
use std::time::{Duration, Instant};
pub struct ApprovalsApp {
state: ViewState,
choices: tokio::sync::mpsc::Sender<CompanionChoice>,
events: std::sync::mpsc::Receiver<CompanionEvent>,
snippet_buf: String,
snippet_for: Option<String>,
sessions: Vec<SessionInfo>,
sessions_refreshed: Instant,
smoke_remaining: Option<u32>,
frames_seen: u64,
watch: bool,
}
impl ApprovalsApp {
pub fn new(
state: ViewState,
choices: tokio::sync::mpsc::Sender<CompanionChoice>,
events: std::sync::mpsc::Receiver<CompanionEvent>,
smoke_frames: Option<u32>,
watch: bool,
) -> Self {
Self {
state,
choices,
events,
snippet_buf: String::new(),
snippet_for: None,
sessions: Vec::new(),
sessions_refreshed: Instant::now()
.checked_sub(Duration::from_secs(10))
.unwrap_or_else(Instant::now),
smoke_remaining: smoke_frames,
frames_seen: 0,
watch,
}
}
fn send_choice(&mut self, ctx: &egui::Context, choice: GrantChoice) {
let Some(p) = self.state.pending.as_mut() else {
return;
};
if p.answering.is_some() {
return;
}
p.answering = Some(choice.clone());
let answer = CompanionChoice {
id: p.request.id.clone(),
choice: choice.clone(),
};
if self.choices.try_send(answer).is_err() {
apply_not_delivered(&mut self.state, choice);
}
if !self.watch {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
ctx.request_repaint();
}
}
fn render(&mut self, ui: &mut egui::Ui) {
ui.heading("sequel-mcp approvals");
ui.add_space(4.0);
if self.watch {
let (color, label) = match &self.state.status {
Status::Connecting => (egui::Color32::YELLOW, "connecting…".to_string()),
Status::Waiting => (
egui::Color32::LIGHT_GREEN,
"waiting for approval requests".to_string(),
),
Status::Disconnected(reason) => (
egui::Color32::LIGHT_RED,
format!("no server ({reason}) — retrying"),
),
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new("●").color(color).strong());
ui.label(label);
});
ui.label(
egui::RichText::new(format!("socket: {}", self.state.socket.display()))
.small()
.weak(),
);
let live = self.sessions.iter().filter(|s| s.alive).count();
ui.label(
egui::RichText::new(if live == 0 {
"servers live: 0 — start one with `sequel-mcp serve`".to_string()
} else {
format!(
"servers live: {live} (pids {})",
self.sessions
.iter()
.filter(|s| s.alive)
.map(|s| s.pid.to_string())
.collect::<Vec<_>>()
.join(", ")
)
})
.small()
.weak(),
);
ui.add_space(6.0);
}
let mut action: Option<GrantChoice> = None;
if let Some(p) = self.state.pending.as_ref() {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!(" {} ", p.request.category.to_uppercase()))
.background_color(category_color(&p.request.category))
.color(egui::Color32::WHITE)
.strong(),
);
ui.label(egui::RichText::new("confirmation required").strong());
});
ui.add_space(6.0);
egui::Grid::new("request-fields")
.num_columns(2)
.spacing([12.0, 4.0])
.show(ui, |ui| {
ui.label("connection");
ui.strong(&p.request.connection);
ui.end_row();
if let Some(db) = &p.request.database {
ui.label("database");
ui.label(db);
ui.end_row();
}
if !p.request.tables.is_empty() {
ui.label("tables");
ui.label(p.request.tables.join(", "));
ui.end_row();
}
});
ui.add_space(4.0);
egui::Frame::default()
.fill(ui.visuals().code_bg_color)
.inner_margin(egui::Margin::symmetric(8, 8))
.corner_radius(egui::CornerRadius::same(4))
.show(ui, |ui| {
egui::ScrollArea::vertical()
.max_height(120.0)
.show(ui, |ui| {
let rows = 2.max(snippet_rows(&self.snippet_buf)).min(12);
ui.add(
egui::TextEdit::multiline(&mut self.snippet_buf)
.font(egui::TextStyle::Monospace)
.desired_rows(rows)
.desired_width(f32::INFINITY)
.interactive(false),
);
});
});
let secs = p.arrived.elapsed().as_secs();
if secs >= 55 {
ui.label(
egui::RichText::new(format!(
"waiting {secs}s — server deadline is 60s, this may already be expired"
))
.color(egui::Color32::from_rgb(0xff, 0xb3, 0x00)),
);
} else {
ui.label(egui::RichText::new(format!("waiting {secs}s")).weak());
}
ui.add_space(4.0);
match &p.answering {
Some(choice) => {
ui.label(format!("delivering {}…", choice_word(choice)));
}
None => {
ui.horizontal(|ui| {
if ui
.add_sized([160.0, 32.0], egui::Button::new("Approve once"))
.clicked()
{
action = Some(GrantChoice::Once);
}
if ui
.add_sized([190.0, 32.0], egui::Button::new("Approve for session"))
.clicked()
{
action = Some(GrantChoice::Session);
}
if ui
.add_sized(
[120.0, 32.0],
egui::Button::new(
egui::RichText::new("Decline").color(egui::Color32::WHITE),
)
.fill(egui::Color32::from_rgb(0xb7, 0x1c, 0x1c)),
)
.clicked()
{
action = Some(GrantChoice::Decline);
}
});
}
}
});
} else {
ui.label(
egui::RichText::new(
"No pending request. When the server needs a confirmation it appears here.",
)
.weak(),
);
}
if self.watch {
ui.add_space(8.0);
ui.separator();
ui.label(egui::RichText::new("Recent answers").strong());
if self.state.history.is_empty() {
ui.weak("nothing answered yet");
} else {
egui::ScrollArea::vertical()
.max_height(160.0)
.show(ui, |ui| {
egui::Grid::new("history")
.num_columns(4)
.spacing([12.0, 3.0])
.show(ui, |ui| {
for h in self.state.history.iter().take(50) {
ui.monospace(&h.ts);
ui.label(
egui::RichText::new(&h.choice)
.color(choice_color(h.choice.as_str())),
);
ui.label(&h.connection);
ui.weak(&h.result);
ui.end_row();
}
});
});
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
ui.weak(format!(
"answered this session: {} · frames: {}",
self.state.answered_total, self.frames_seen
));
});
}
if let Some(choice) = action {
self.send_choice(ui.ctx(), choice);
}
}
}
impl eframe::App for ApprovalsApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.process_events(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.frames_seen += 1;
self.render(ui);
if self.state.pending.is_some() {
ui.ctx().request_repaint_after(Duration::from_millis(250));
} else if self.watch || self.smoke_remaining.is_some() {
ui.ctx().request_repaint_after(Duration::from_secs(1));
}
if let Some(n) = &mut self.smoke_remaining {
*n = n.saturating_sub(1);
if *n == 0 {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
}
impl ApprovalsApp {
fn process_events(&mut self, ctx: &egui::Context) {
while let Ok(event) = self.events.try_recv() {
apply_event(&mut self.state, event);
}
if !self.watch && self.state.finished {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
return;
}
if self.watch && self.sessions_refreshed.elapsed() >= Duration::from_secs(5) {
self.sessions = super::live_sessions();
self.sessions_refreshed = Instant::now();
}
let pending_id = self.state.pending.as_ref().map(|p| p.request.id.clone());
if pending_id != self.snippet_for {
if pending_id.is_some() {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
self.snippet_for = pending_id;
self.snippet_buf = self
.state
.pending
.as_ref()
.map(|p| p.request.snippet.clone())
.unwrap_or_default();
}
}
}
fn choice_word(choice: &GrantChoice) -> &'static str {
match choice {
GrantChoice::Once => "once",
GrantChoice::Session => "session",
GrantChoice::Decline => "decline",
}
}
fn choice_color(word: &str) -> egui::Color32 {
match word {
"once" => egui::Color32::LIGHT_GREEN,
"session" => egui::Color32::LIGHT_BLUE,
"decline" => egui::Color32::LIGHT_RED,
_ => egui::Color32::GRAY,
}
}
fn category_color(category: &str) -> egui::Color32 {
let c = category.to_ascii_lowercase();
if c.contains("ddl") || c.contains("admin") || c.contains("drop") {
egui::Color32::from_rgb(0xc6, 0x28, 0x28)
} else if c.contains("write")
|| c.contains("insert")
|| c.contains("update")
|| c.contains("delete")
{
egui::Color32::from_rgb(0xef, 0x6c, 0x00)
} else {
egui::Color32::from_rgb(0x37, 0x47, 0x4f)
}
}
fn snippet_rows(snippet: &str) -> usize {
snippet.lines().count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::approval::ipc::ApprovalRequest;
fn setup(
watch: bool,
) -> (
ApprovalsApp,
std::sync::mpsc::Sender<CompanionEvent>,
tokio::sync::mpsc::Receiver<CompanionChoice>,
) {
let (events, rx) = std::sync::mpsc::channel();
let (choices, answers) = tokio::sync::mpsc::channel(8);
let mut app = ApprovalsApp::new(
ViewState::new("/tmp/approval.sock".into()),
choices,
rx,
None,
watch,
);
app.sessions_refreshed = Instant::now();
(app, events, answers)
}
fn request() -> CompanionEvent {
CompanionEvent::Request {
request: ApprovalRequest {
id: "r1".into(),
category: "write".into(),
connection: "local-dev".into(),
database: Some("app".into()),
tables: vec!["app.items".into()],
snippet: "UPDATE items SET id = 2".into(),
},
}
}
fn commands(ctx: &egui::Context) -> Vec<egui::ViewportCommand> {
ctx.run_logic(&egui::RawInput::default(), |_| {})
.viewport_commands
.remove(&egui::ViewportId::ROOT)
.unwrap_or_default()
}
#[test]
fn prompt_shows_focuses_hides_on_each_choice_and_closes_after_ack() {
for choice in [
GrantChoice::Once,
GrantChoice::Session,
GrantChoice::Decline,
] {
let ctx = egui::Context::default();
let (mut app, events, mut answers) = setup(false);
events.send(request()).unwrap();
app.process_events(&ctx);
let cmds = commands(&ctx);
assert!(cmds.contains(&egui::ViewportCommand::Visible(true)));
assert!(cmds.contains(&egui::ViewportCommand::Focus));
app.send_choice(&ctx, choice.clone());
assert!(commands(&ctx).contains(&egui::ViewportCommand::Visible(false)));
let answer = answers.try_recv().unwrap();
assert_eq!(answer.id, "r1");
assert_eq!(answer.choice, choice);
app.send_choice(&ctx, choice.clone());
assert!(
answers.try_recv().is_err(),
"double clicks must not queue another choice"
);
events
.send(CompanionEvent::Acked {
id: "r1".into(),
choice,
})
.unwrap();
app.process_events(&ctx);
assert!(commands(&ctx).contains(&egui::ViewportCommand::Close));
}
}
#[test]
fn prompt_closes_without_an_answer_on_expiry_disconnect_or_empty() {
for terminal in [
CompanionEvent::Stale { id: "r1".into() },
CompanionEvent::Disconnected {
reason: "gone".into(),
},
CompanionEvent::Empty,
] {
let ctx = egui::Context::default();
let (mut app, events, mut answers) = setup(false);
events.send(request()).unwrap();
app.process_events(&ctx);
commands(&ctx);
events.send(terminal).unwrap();
app.process_events(&ctx);
assert!(commands(&ctx).contains(&egui::ViewportCommand::Close));
assert_eq!(app.state.answered_total, 0);
assert!(answers.try_recv().is_err());
}
}
#[test]
fn watch_mode_stays_open_after_answer() {
let ctx = egui::Context::default();
let (mut app, events, _) = setup(true);
events.send(request()).unwrap();
app.process_events(&ctx);
commands(&ctx);
events
.send(CompanionEvent::Acked {
id: "r1".into(),
choice: GrantChoice::Once,
})
.unwrap();
app.process_events(&ctx);
assert!(!commands(&ctx).contains(&egui::ViewportCommand::Close));
assert_eq!(app.state.answered_total, 1);
}
}