use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem, ListState, Paragraph};
use crate::theme::Theme;
pub const STATUS_UNAVAILABLE: &str = "Journal unavailable — non-durable mode";
pub const STATUS_GATE_REJECTED: &str =
"Durable journal disabled by encryption policy (INV-8) — see logs";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DurableRow {
pub id_short: String,
pub kind: String,
pub status: String,
pub step_count: u64,
pub age_secs: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DurableStatus {
#[default]
Unavailable,
GateRejected,
Available,
}
#[derive(Debug, Clone, Default)]
pub struct DurableSnapshot {
pub status: DurableStatus,
pub executions: Vec<DurableRow>,
pub key_id: u8,
pub previous_key_id: Option<u8>,
}
fn status_color(status: &str) -> Color {
match status {
"running" => Color::Green,
"completed" => Color::DarkGray,
"failed" => Color::Red,
"aborted" => Color::Yellow,
"canceled" => Color::Magenta,
_ => Color::White,
}
}
fn fmt_age(secs: u64) -> String {
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3_600 => format!("{}m", s / 60),
s if s < 86_400 => format!("{}h", s / 3_600),
s => format!("{}d", s / 86_400),
}
}
pub fn render(
snapshot: &DurableSnapshot,
frame: &mut Frame,
area: Rect,
list_state: &mut ListState,
theme: &Theme,
) {
frame.render_widget(Clear, area);
let exec_count = snapshot.executions.len();
let mut header_spans = vec![Span::styled(
format!("durable · {exec_count} key_id={}", snapshot.key_id),
theme.system_message.add_modifier(Modifier::BOLD),
)];
if let Some(previous_key_id) = snapshot.previous_key_id {
header_spans.push(Span::styled(
format!(" rotation window open (previous_key_id = {previous_key_id})"),
Style::default().fg(Color::Yellow),
));
}
header_spans.push(Span::styled(
" [D]",
theme.system_message.add_modifier(Modifier::BOLD),
));
let header = Line::from(header_spans);
let splits = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(area);
frame.render_widget(Paragraph::new(header), splits[0]);
match snapshot.status {
DurableStatus::Unavailable => {
let msg = Paragraph::new(STATUS_UNAVAILABLE).style(Style::default().fg(Color::Yellow));
frame.render_widget(msg, splits[1]);
return;
}
DurableStatus::GateRejected => {
let msg = Paragraph::new(STATUS_GATE_REJECTED).style(Style::default().fg(Color::Red));
frame.render_widget(msg, splits[1]);
return;
}
DurableStatus::Available => {}
}
if snapshot.executions.is_empty() {
let msg = Paragraph::new("No durable executions recorded.")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, splits[1]);
return;
}
let col_header = ListItem::new(Line::from(vec![Span::styled(
format!(
" {:<10} {:<16}{:<10}{:>6} {:>6}",
"ID", "KIND", "STATUS", "STEPS", "AGE"
),
Style::default().add_modifier(Modifier::BOLD),
)]));
let mut items: Vec<ListItem> = vec![col_header];
for (i, row) in snapshot.executions.iter().enumerate() {
let selected = list_state.selected() == Some(i + 1);
let base = if selected {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let color = status_color(&row.status);
let line = Line::from(vec![
Span::styled(format!(" {:<10} ", row.id_short), base),
Span::styled(format!("{:<16}", row.kind), base),
Span::styled(format!("{:<10}", row.status), base.fg(color)),
Span::styled(format!("{:>6}", row.step_count), base),
Span::styled(format!(" {:>6}", fmt_age(row.age_secs)), base),
]);
items.push(ListItem::new(line));
}
let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
frame.render_stateful_widget(list, splits[1], list_state);
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
#[test]
fn fmt_age_scales_units() {
assert_eq!(fmt_age(12), "12s");
assert_eq!(fmt_age(300), "5m");
assert_eq!(fmt_age(7_200), "2h");
assert_eq!(fmt_age(172_800), "2d");
}
#[test]
fn status_color_maps_known_states() {
assert_eq!(status_color("running"), Color::Green);
assert_eq!(status_color("failed"), Color::Red);
assert_eq!(status_color("canceled"), Color::Magenta);
assert_eq!(status_color("mystery"), Color::White);
}
fn render_over_sentinel(snapshot: &DurableSnapshot) -> ratatui::buffer::Buffer {
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut list_state = ListState::default();
terminal
.draw(|frame| {
let area = frame.area();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
frame.buffer_mut()[(x, y)].set_symbol("#");
}
}
render(snapshot, frame, area, &mut list_state, &Theme::default());
})
.unwrap();
terminal.backend().buffer().clone()
}
#[test]
fn render_clears_stale_glyphs_before_drawing_unavailable_status() {
let snapshot = DurableSnapshot {
status: DurableStatus::Unavailable,
executions: Vec::new(),
..DurableSnapshot::default()
};
let buf = render_over_sentinel(&snapshot);
for cell in &buf.content {
assert_ne!(
cell.symbol(),
"#",
"stray sentinel glyph survived render — Clear is missing or not applied to the whole area"
);
}
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
assert!(
rendered.contains(STATUS_UNAVAILABLE),
"expected unavailable status text, got: {rendered:?}"
);
}
#[test]
fn render_clears_stale_glyphs_before_drawing_gate_rejected_status() {
let snapshot = DurableSnapshot {
status: DurableStatus::GateRejected,
executions: Vec::new(),
..DurableSnapshot::default()
};
let buf = render_over_sentinel(&snapshot);
for cell in &buf.content {
assert_ne!(
cell.symbol(),
"#",
"stray sentinel glyph survived render — Clear is missing or not applied to the whole area"
);
}
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
assert!(
rendered.contains(STATUS_GATE_REJECTED),
"expected gate-rejected status text, got: {rendered:?}"
);
}
#[test]
fn render_clears_stale_glyphs_before_drawing_available_list() {
let snapshot = DurableSnapshot {
status: DurableStatus::Available,
executions: vec![DurableRow {
id_short: "abcd1234".into(),
kind: "agent_turn".into(),
status: "running".into(),
step_count: 3,
age_secs: 12,
}],
..DurableSnapshot::default()
};
let buf = render_over_sentinel(&snapshot);
for cell in &buf.content {
assert_ne!(
cell.symbol(),
"#",
"stray sentinel glyph survived render — Clear is missing or not applied to the whole area"
);
}
}
#[test]
fn unavailable_and_gate_rejected_render_distinct_messages() {
assert_ne!(STATUS_UNAVAILABLE, STATUS_GATE_REJECTED);
let unavailable = render_over_sentinel(&DurableSnapshot {
status: DurableStatus::Unavailable,
executions: Vec::new(),
..DurableSnapshot::default()
});
let rendered_unavailable: String = unavailable
.content
.iter()
.map(|c| c.symbol().to_owned())
.collect();
assert!(rendered_unavailable.contains(STATUS_UNAVAILABLE));
assert!(!rendered_unavailable.contains(STATUS_GATE_REJECTED));
let gate_rejected = render_over_sentinel(&DurableSnapshot {
status: DurableStatus::GateRejected,
executions: Vec::new(),
..DurableSnapshot::default()
});
let rendered_gate_rejected: String = gate_rejected
.content
.iter()
.map(|c| c.symbol().to_owned())
.collect();
assert!(rendered_gate_rejected.contains(STATUS_GATE_REJECTED));
assert!(!rendered_gate_rejected.contains(STATUS_UNAVAILABLE));
}
#[test]
fn available_status_falls_through_to_executions_list() {
let snapshot = DurableSnapshot {
status: DurableStatus::Available,
executions: vec![DurableRow {
id_short: "deadbeef".into(),
kind: "dag_run".into(),
status: "completed".into(),
step_count: 7,
age_secs: 300,
}],
..DurableSnapshot::default()
};
let buf = render_over_sentinel(&snapshot);
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
assert!(
rendered.contains("deadbeef"),
"expected execution row to render, got: {rendered:?}"
);
assert!(!rendered.contains(STATUS_UNAVAILABLE));
assert!(!rendered.contains(STATUS_GATE_REJECTED));
}
#[test]
fn available_status_with_empty_executions_renders_placeholder() {
let snapshot = DurableSnapshot {
status: DurableStatus::Available,
executions: Vec::new(),
..DurableSnapshot::default()
};
let buf = render_over_sentinel(&snapshot);
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
assert!(rendered.contains("No durable executions recorded."));
assert!(!rendered.contains(STATUS_UNAVAILABLE));
assert!(!rendered.contains(STATUS_GATE_REJECTED));
}
#[test]
fn header_always_shows_current_key_id() {
let snapshot = DurableSnapshot {
status: DurableStatus::Available,
executions: Vec::new(),
key_id: 3,
previous_key_id: None,
};
let buf = render_over_sentinel(&snapshot);
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
assert!(
rendered.contains("key_id=3"),
"expected current key_id in header, got: {rendered:?}"
);
assert!(
!rendered.contains("rotation window open"),
"no rotation window is open, indicator must not render, got: {rendered:?}"
);
}
#[test]
fn rotation_window_open_renders_passive_indicator_with_previous_key_id() {
let snapshot = DurableSnapshot {
status: DurableStatus::Available,
executions: Vec::new(),
key_id: 2,
previous_key_id: Some(1),
};
let buf = render_over_sentinel(&snapshot);
let rendered: String = buf.content.iter().map(|c| c.symbol().to_owned()).collect();
assert!(
rendered.contains("key_id=2"),
"expected current key_id in header, got: {rendered:?}"
);
assert!(
rendered.contains("rotation window open (previous_key_id = 1)"),
"expected passive rotation window indicator, got: {rendered:?}"
);
}
}