use color_eyre::Result;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use super::Component;
use crate::action::{Action, ApprovalDecision, Pane};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ApprovalView {
pub turn_id: String,
pub conversation_id: String,
pub request_id: String,
pub tool_name: String,
pub args_json: String,
pub resolve_token: String,
pub response: Option<ApprovalOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ApprovalOutcome {
pub approved: bool,
pub reason: String,
pub signer_pk_hex: String,
pub signature_hex: String,
pub signature_valid: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
enum Editing {
#[default]
Idle,
Reason {
approved: bool,
buffer: String,
},
}
#[derive(Default)]
pub(crate) struct Approvals {
pub conversation_id: Option<String>,
pub items: Vec<ApprovalView>,
pub selected: Option<usize>,
focused: bool,
editing: Editing,
last_error: Option<String>,
}
impl Approvals {
fn upsert(&mut self, view: ApprovalView) -> usize {
if let Some(idx) = self.index_of(&view.conversation_id, &view.turn_id, &view.request_id) {
self.items[idx] = view;
idx
} else {
self.items.insert(0, view);
if let Some(sel) = self.selected.as_mut() {
*sel += 1;
}
0
}
}
fn index_of(&self, conversation_id: &str, turn_id: &str, request_id: &str) -> Option<usize> {
self.items.iter().position(|v| {
v.conversation_id == conversation_id
&& v.turn_id == turn_id
&& v.request_id == request_id
})
}
#[must_use]
pub(crate) fn pending_count(&self) -> usize {
self.items.iter().filter(|v| v.response.is_none()).count()
}
fn move_selection(&mut self, delta: isize) {
self.selected = super::step_selection(self.selected, self.items.len(), delta);
}
fn decision_for_selected(&self, approved: bool, reason: String) -> Option<ApprovalDecision> {
let item = self.items.get(self.selected?)?;
if item.response.is_some() {
return None;
}
Some(ApprovalDecision {
turn_id: item.turn_id.clone(),
request_id: item.request_id.clone(),
conversation_id: item.conversation_id.clone(),
tool_name: item.tool_name.clone(),
args_json: item.args_json.clone(),
approved,
reason,
resolve_token: item.resolve_token.clone(),
})
}
fn on_key(&mut self, key: KeyEvent) -> Option<Action> {
if let Editing::Reason { approved, buffer } = &mut self.editing {
match key.code {
KeyCode::Char(c) => {
buffer.push(c);
return None;
}
KeyCode::Backspace => {
buffer.pop();
return None;
}
KeyCode::Esc => {
self.editing = Editing::Idle;
return None;
}
KeyCode::Enter => {
let approved = *approved;
let reason = std::mem::take(buffer);
self.editing = Editing::Idle;
return self.emit_decision(approved, reason);
}
_ => return None,
}
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.move_selection(1);
None
}
KeyCode::Char('k') | KeyCode::Up => {
self.move_selection(-1);
None
}
KeyCode::Char('a') => self.emit_decision(true, "approved via cockpit".to_owned()),
KeyCode::Char('r') => self.emit_decision(false, "rejected via cockpit".to_owned()),
KeyCode::Char('e' | 'A') => {
if self.decision_for_selected(true, String::new()).is_some() {
self.editing = Editing::Reason {
approved: true,
buffer: String::new(),
};
}
None
}
KeyCode::Char('R') => {
if self.decision_for_selected(false, String::new()).is_some() {
self.editing = Editing::Reason {
approved: false,
buffer: String::new(),
};
}
None
}
_ => None,
}
}
fn emit_decision(&mut self, approved: bool, reason: String) -> Option<Action> {
if let Some(decision) = self.decision_for_selected(approved, reason) {
self.last_error = None;
Some(Action::ApprovalDecide(decision))
} else {
self.last_error = Some("no pending approval selected (already decided?)".to_owned());
None
}
}
}
impl Component for Approvals {
fn controls(&self) -> Vec<(&'static str, &'static str)> {
if matches!(self.editing, Editing::Reason { .. }) {
return vec![("Enter", "submit"), ("Esc", "cancel")];
}
vec![
("↑↓/jk", "move"),
("a", "approve"),
("r", "reject"),
("e/R", "+reason"),
]
}
fn capturing_input(&self) -> bool {
matches!(self.editing, Editing::Reason { .. })
}
fn handle(&mut self, action: &Action) -> Option<Action> {
match action {
Action::Nav(pane) => {
self.focused = *pane == Pane::Approvals;
if !self.focused {
self.editing = Editing::Idle;
}
None
}
Action::Select(conversation_id) => {
self.conversation_id = Some(conversation_id.clone());
None
}
Action::ApprovalPending(view) => {
let idx = self.upsert(view.clone());
if self.selected.is_none() {
self.selected = Some(idx);
}
None
}
Action::ApprovalSubmitted {
conversation_id,
turn_id,
request_id,
persisted,
outcome,
} => {
if let Some(idx) = self.items.iter().position(|v| {
&v.conversation_id == conversation_id
&& &v.turn_id == turn_id
&& &v.request_id == request_id
&& v.response.is_none()
}) {
if *persisted {
if let Some(outcome) = outcome {
self.items[idx].response = Some(outcome.clone());
}
self.last_error = None;
} else {
self.last_error = Some(format!(
"control plane did not persist decision for {request_id} (already answered or unknown request)"
));
}
}
None
}
Action::Error(msg) => {
self.last_error = Some(msg.clone());
None
}
Action::Key(key) if self.focused => self.on_key(*key),
_ => None,
}
}
#[allow(clippy::too_many_lines)] fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let pending = self.pending_count();
let title = self.conversation_id.as_ref().map_or_else(
|| format!(" approvals — {pending} pending "),
|id| format!(" approvals — {pending} pending (sel conv {id}) "),
);
let outer = Block::default().title(title).borders(Borders::ALL);
let inner = outer.inner(area);
frame.render_widget(outer, area);
if self.items.is_empty() {
let empty = Paragraph::new("no approvals — the inbox is clear")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(empty, inner);
return Ok(());
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(inner);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(2)])
.split(cols[1]);
let list_items: Vec<ListItem> = self
.items
.iter()
.map(|v| {
let (marker, marker_style) = match &v.response {
None => ("● ", Style::default().fg(Color::Yellow)),
Some(o) if o.approved => ("✓ ", Style::default().fg(Color::Green)),
Some(_) => ("✗ ", Style::default().fg(Color::Red)),
};
ListItem::new(Line::from(vec![
Span::styled(marker, marker_style),
Span::styled(
v.tool_name.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
short(&v.conversation_id),
Style::default().fg(Color::DarkGray),
),
]))
})
.collect();
let list = List::new(list_items)
.block(Block::default().borders(Borders::RIGHT))
.highlight_style(
Style::default()
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
let mut state = ListState::default();
state.select(self.selected);
frame.render_stateful_widget(list, cols[0], &mut state);
let detail = self.selected.and_then(|s| self.items.get(s));
let detail_text = detail.map_or_else(
|| Line::from("select an approval").into(),
|v| {
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::styled("tool ", Style::default().fg(Color::DarkGray)),
Span::styled(
v.tool_name.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("call ", Style::default().fg(Color::DarkGray)),
Span::raw(v.request_id.clone()),
]),
Line::from(vec![
Span::styled("conv ", Style::default().fg(Color::DarkGray)),
Span::raw(v.conversation_id.clone()),
]),
Line::from(""),
];
lines.push(Line::from(Span::styled(
"args",
Style::default().fg(Color::DarkGray),
)));
for l in pretty_json(&v.args_json).lines() {
lines.push(Line::from(l.to_owned()));
}
lines.push(Line::from(""));
if let Some(o) = &v.response {
let verdict = if o.approved { "APPROVED" } else { "REJECTED" };
let vstyle = if o.approved {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Red)
};
lines.push(Line::from(Span::styled(verdict, vstyle)));
lines.push(Line::from(format!("reason: {}", o.reason)));
lines.push(Line::from(format!("signer: {}", short(&o.signer_pk_hex))));
let sig = if o.signature_valid {
Span::styled("signature verified", Style::default().fg(Color::Green))
} else {
Span::styled("signature INVALID", Style::default().fg(Color::Red))
};
lines.push(Line::from(sig));
} else {
lines.push(Line::from(Span::styled(
"[a] approve [r] reject [e] approve+reason [R] reject+reason",
Style::default().fg(Color::Cyan),
)));
}
ratatui::text::Text::from(lines)
},
);
let detail_widget = Paragraph::new(detail_text).wrap(Wrap { trim: false });
frame.render_widget(detail_widget, rows[0]);
let footer = match (&self.editing, &self.last_error) {
(Editing::Reason { approved, buffer }, _) => {
let verb = if *approved { "approve" } else { "reject" };
Paragraph::new(format!(
"{verb} reason> {buffer}▏ (Enter submit · Esc cancel)"
))
.style(Style::default().fg(Color::Yellow))
}
(Editing::Idle, Some(err)) => {
Paragraph::new(err.clone()).style(Style::default().fg(Color::Red))
}
(Editing::Idle, None) => Paragraph::new(""),
};
frame.render_widget(footer, rows[1]);
Ok(())
}
}
fn pretty_json(raw: &str) -> String {
serde_json::from_str::<serde_json::Value>(raw)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| raw.to_owned())
}
fn short(s: &str) -> String {
let count = s.chars().count();
if count <= 12 {
s.to_owned()
} else {
let head: String = s.chars().take(6).collect();
let tail: String = s.chars().skip(count - 4).collect();
format!("{head}…{tail}")
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn pending(conv: &str, req: &str) -> ApprovalView {
pending_turn(conv, "00000000-0000-0000-0000-000000000001", req)
}
fn pending_turn(conv: &str, turn: &str, req: &str) -> ApprovalView {
ApprovalView {
conversation_id: conv.to_owned(),
turn_id: turn.to_owned(),
request_id: req.to_owned(),
tool_name: "rm".to_owned(),
args_json: r#"{"path":"/tmp"}"#.to_owned(),
resolve_token: "tok".to_owned(),
response: None,
}
}
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
#[test]
fn pending_action_inserts_and_selects() {
let mut pane = Approvals::default();
assert!(
pane.handle(&Action::ApprovalPending(pending("c1", "call-1")))
.is_none()
);
assert_eq!(pane.items.len(), 1);
assert_eq!(pane.selected, Some(0));
assert_eq!(pane.pending_count(), 1);
}
#[test]
fn pending_is_deduped_on_identity() {
let mut pane = Approvals::default();
pane.handle(&Action::ApprovalPending(pending("c1", "call-1")));
pane.handle(&Action::ApprovalPending(pending("c1", "call-1")));
assert_eq!(pane.items.len(), 1);
}
#[test]
fn reused_request_id_keeps_distinct_turn_occurrences() {
let mut pane = Approvals::default();
pane.handle(&Action::ApprovalPending(pending_turn(
"c1",
"00000000-0000-0000-0000-000000000001",
"call-1",
)));
pane.handle(&Action::ApprovalPending(pending_turn(
"c1",
"00000000-0000-0000-0000-000000000002",
"call-1",
)));
assert_eq!(pane.items.len(), 2);
pane.handle(&Action::ApprovalSubmitted {
conversation_id: "c1".to_owned(),
turn_id: "00000000-0000-0000-0000-000000000001".to_owned(),
request_id: "call-1".to_owned(),
persisted: true,
outcome: Some(ApprovalOutcome {
approved: true,
reason: "approved".to_owned(),
signer_pk_hex: "ab".to_owned(),
signature_hex: "cd".to_owned(),
signature_valid: true,
}),
});
assert!(pane.items.iter().any(|item| {
item.turn_id == "00000000-0000-0000-0000-000000000001" && item.response.is_some()
}));
assert!(pane.items.iter().any(|item| {
item.turn_id == "00000000-0000-0000-0000-000000000002" && item.response.is_none()
}));
}
#[test]
fn newest_first_ordering_shifts_selection() {
let mut pane = Approvals::default();
pane.handle(&Action::ApprovalPending(pending("c1", "call-1")));
pane.handle(&Action::ApprovalPending(pending("c1", "call-2")));
assert_eq!(pane.items[0].request_id, "call-2");
assert_eq!(pane.selected, Some(1));
}
#[test]
fn approve_key_emits_decision_only_when_focused() {
let mut pane = Approvals::default();
pane.handle(&Action::ApprovalPending(pending("c1", "call-1")));
assert!(pane.handle(&Action::Key(key('a'))).is_none());
pane.handle(&Action::Nav(Pane::Approvals));
let out = pane.handle(&Action::Key(key('a')));
match out {
Some(Action::ApprovalDecide(d)) => {
assert!(d.approved);
assert_eq!(d.request_id, "call-1");
assert_eq!(d.conversation_id, "c1");
}
other => panic!("expected ApprovalDecide, got {other:?}"),
}
}
#[test]
fn reject_key_emits_negative_decision() {
let mut pane = Approvals::default();
pane.handle(&Action::ApprovalPending(pending("c1", "call-1")));
pane.handle(&Action::Nav(Pane::Approvals));
let out = pane.handle(&Action::Key(key('r')));
assert!(matches!(out, Some(Action::ApprovalDecide(d)) if !d.approved));
}
#[test]
fn reason_editing_collects_text_then_submits() {
let mut pane = Approvals::default();
pane.handle(&Action::ApprovalPending(pending("c1", "call-1")));
pane.handle(&Action::Nav(Pane::Approvals));
assert!(pane.handle(&Action::Key(key('e'))).is_none());
assert!(pane.handle(&Action::Key(key('o'))).is_none());
assert!(pane.handle(&Action::Key(key('k'))).is_none());
let out = pane.handle(&Action::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)));
match out {
Some(Action::ApprovalDecide(d)) => {
assert!(d.approved);
assert_eq!(d.reason, "ok");
}
other => panic!("expected ApprovalDecide, got {other:?}"),
}
}
#[test]
fn cannot_decide_already_decided_item() {
let mut pane = Approvals::default();
let mut decided = pending("c1", "call-1");
decided.response = Some(ApprovalOutcome {
approved: true,
reason: "done".to_owned(),
signer_pk_hex: "ab".to_owned(),
signature_hex: "cd".to_owned(),
signature_valid: true,
});
pane.handle(&Action::ApprovalPending(decided));
pane.handle(&Action::Nav(Pane::Approvals));
assert!(pane.handle(&Action::Key(key('a'))).is_none());
assert!(pane.last_error.is_some());
assert_eq!(pane.pending_count(), 0);
}
#[test]
fn submitted_not_persisted_sets_error() {
let mut pane = Approvals::default();
pane.handle(&Action::ApprovalPending(pending("c1", "call-1")));
pane.handle(&Action::ApprovalSubmitted {
conversation_id: "c1".to_owned(),
turn_id: "00000000-0000-0000-0000-000000000001".to_owned(),
request_id: "call-1".to_owned(),
persisted: false,
outcome: None,
});
assert!(pane.last_error.is_some());
}
#[test]
fn pretty_json_falls_back_on_non_json() {
assert_eq!(pretty_json("not json"), "not json");
assert_eq!(pretty_json(r#"{"a":1}"#), "{\n \"a\": 1\n}");
}
}