use std::{io, time::Duration};
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use serde_json::{Value, json};
use crate::daemon;
const STATUS_TIMEOUT: Duration = Duration::from_secs(10);
const TASK_RUN_TIMEOUT: Duration = Duration::from_secs(30 * 60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Screen {
Status,
Sessions,
SessionDetail,
RunTask,
}
impl Screen {
fn next(self) -> Self {
match self {
Self::Status => Self::Sessions,
Self::Sessions => Self::SessionDetail,
Self::SessionDetail => Self::RunTask,
Self::RunTask => Self::Status,
}
}
fn label(self) -> &'static str {
match self {
Self::Status => "Status",
Self::Sessions => "Sessions",
Self::SessionDetail => "Session",
Self::RunTask => "Run task",
}
}
}
struct ClientState {
addr: String,
screen: Screen,
status: Option<Value>,
status_error: Option<String>,
sessions: Vec<Value>,
sessions_error: Option<String>,
selected_session: usize,
detail: Option<Value>,
detail_error: Option<String>,
contract: String,
policy: String,
trust_workspace: bool,
focus_contract: bool,
running: bool,
run_result: Option<Result<Value, String>>,
}
impl ClientState {
fn new(addr: String) -> Self {
Self {
addr,
screen: Screen::Status,
status: None,
status_error: None,
sessions: Vec::new(),
sessions_error: None,
selected_session: 0,
detail: None,
detail_error: None,
contract: String::new(),
policy: String::new(),
trust_workspace: false,
focus_contract: true,
running: false,
run_result: None,
}
}
async fn refresh_status(&mut self) {
match daemon::request_with_timeout(&self.addr, "daemon.status", json!({}), STATUS_TIMEOUT)
.await
{
Ok(value) => {
self.status = Some(value);
self.status_error = None;
}
Err(error) => {
self.status = None;
self.status_error = Some(format!("{error:#}"));
}
}
}
async fn refresh_sessions(&mut self) {
match daemon::request(&self.addr, "daemon.sessions", json!({})).await {
Ok(value) => {
self.sessions = value
.get("sessions")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
self.sessions_error = None;
self.selected_session = 0;
}
Err(error) => {
self.sessions_error = Some(format!("{error:#}"));
}
}
}
async fn load_detail(&mut self) {
let Some(session) = self.sessions.get(self.selected_session) else {
return;
};
let id = session
.get("id")
.and_then(Value::as_str)
.unwrap_or_default();
if id.is_empty() {
return;
}
match daemon::request(&self.addr, "daemon.session", json!({"id": id})).await {
Ok(value) => {
self.detail = Some(value);
self.detail_error = None;
self.screen = Screen::SessionDetail;
}
Err(error) => {
self.detail_error = Some(format!("{error:#}"));
}
}
}
async fn run_task(&mut self) {
let contract = self.contract.trim();
if contract.is_empty() {
self.run_result = Some(Err("contract path is required".to_owned()));
return;
}
let mut params = json!({
"contract": contract,
"trustWorkspace": self.trust_workspace,
});
if !self.policy.trim().is_empty() {
params["policy"] = json!(self.policy.trim());
}
self.running = true;
self.run_result = None;
let result =
daemon::request_with_timeout(&self.addr, "task.run", params, TASK_RUN_TIMEOUT).await;
self.run_result = Some(result.map_err(|error| format!("{error:#}")));
self.running = false;
}
async fn handle_key(&mut self, code: KeyCode) -> Result<bool> {
if self.running {
return Ok(false);
}
match (self.screen, code) {
(_, KeyCode::Char('q') | KeyCode::Esc) => return Ok(true),
(_, KeyCode::Tab) => self.screen = self.screen.next(),
(Screen::Status, KeyCode::Char('r')) => self.refresh_status().await,
(Screen::Sessions, KeyCode::Char('r')) => self.refresh_sessions().await,
(Screen::Sessions, KeyCode::Up) => {
self.selected_session = self.selected_session.saturating_sub(1);
}
(Screen::Sessions, KeyCode::Down) => {
if self.selected_session + 1 < self.sessions.len() {
self.selected_session += 1;
}
}
(Screen::Sessions, KeyCode::Enter) => self.load_detail().await,
(Screen::SessionDetail, KeyCode::Char('b')) => {
self.screen = Screen::Sessions;
}
(Screen::RunTask, KeyCode::Enter) => self.run_task().await,
(Screen::RunTask, KeyCode::Char('f')) => self.focus_contract = !self.focus_contract,
(Screen::RunTask, KeyCode::Char('t')) => self.trust_workspace = !self.trust_workspace,
(Screen::RunTask, KeyCode::Char('c')) => self.run_result = None,
(Screen::RunTask, KeyCode::Backspace) => {
if self.focus_contract {
self.contract.pop();
} else {
self.policy.pop();
}
}
(Screen::RunTask, KeyCode::Char(character)) if self.focus_contract => {
self.contract.push(character);
}
(Screen::RunTask, KeyCode::Char(character)) => self.policy.push(character),
_ => {}
}
Ok(false)
}
}
pub async fn run(port: u16) -> Result<()> {
let mut terminal = ClientTerminal::start()?;
let mut state = ClientState::new(format!("127.0.0.1:{port}"));
state.refresh_status().await;
state.refresh_sessions().await;
loop {
terminal.terminal.draw(|frame| draw(&state, frame))?;
if !event::poll(Duration::from_millis(200))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if state.handle_key(key.code).await? {
break;
}
}
Ok(())
}
struct ClientTerminal {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
}
impl ClientTerminal {
fn start() -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let terminal = Terminal::new(CrosstermBackend::new(stdout))?;
Ok(Self { terminal })
}
}
impl Drop for ClientTerminal {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
fn draw(state: &ClientState, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(frame.area());
draw_tabs(state, frame, chunks[0]);
match state.screen {
Screen::Status => draw_status(state, frame, chunks[1]),
Screen::Sessions => draw_sessions(state, frame, chunks[1]),
Screen::SessionDetail => draw_detail(state, frame, chunks[1]),
Screen::RunTask => draw_run_task(state, frame, chunks[1]),
}
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("q: quit Tab: screen r: refresh"),
Span::styled(
match state.screen {
Screen::Status => " (status screen)",
Screen::Sessions => " (Enter: detail)",
Screen::SessionDetail => " (b: back)",
Screen::RunTask => " (Enter: run f: field t: trust c: clear)",
},
Style::default().fg(Color::DarkGray),
),
]))
.alignment(Alignment::Left),
chunks[2],
);
}
fn draw_tabs(state: &ClientState, frame: &mut Frame, area: ratatui::layout::Rect) {
let tabs: Vec<Line> = [
Screen::Status,
Screen::Sessions,
Screen::SessionDetail,
Screen::RunTask,
]
.iter()
.map(|screen| {
let active = *screen == state.screen;
let style = if active {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
Line::from(Span::styled(format!(" {} ", screen.label()), style))
})
.collect();
frame.render_widget(
Paragraph::new(tabs).block(
Block::default()
.borders(Borders::BOTTOM)
.title(format!("Proofborne client — daemon {}", state.addr)),
),
area,
);
}
fn draw_status(state: &ClientState, frame: &mut Frame, area: ratatui::layout::Rect) {
let lines = if let Some(error) = &state.status_error {
vec![Line::from(Span::styled(
format!("daemon unavailable: {error}"),
Style::default().fg(Color::Red),
))]
} else if let Some(status) = &state.status {
let version = status.get("version").and_then(Value::as_str).unwrap_or("?");
let workspace = status
.get("workspace")
.and_then(Value::as_str)
.unwrap_or("?");
let pid = status.get("pid").and_then(Value::as_i64).unwrap_or(-1);
let mut lines = vec![
Line::from(format!("Version: {version}")),
Line::from(format!("Workspace: {workspace}")),
Line::from(format!("PID: {pid}")),
];
if let Some(plugins) = status.get("pluginToolCount") {
lines.push(Line::from(format!("Plugins: {plugins} tool(s)")));
}
if let Some(error) = status.get("pluginError").and_then(Value::as_str) {
lines.push(Line::from(Span::styled(
format!("Plugin error: {error}"),
Style::default().fg(Color::Yellow),
)));
}
lines
} else {
vec![Line::from("loading status…")]
};
frame.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title("Daemon status"),
)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_sessions(state: &ClientState, frame: &mut Frame, area: ratatui::layout::Rect) {
if let Some(error) = &state.sessions_error {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("cannot list sessions: {error}"),
Style::default().fg(Color::Red),
)))
.block(Block::default().borders(Borders::ALL).title("Sessions")),
area,
);
return;
}
let items: Vec<ListItem> = state
.sessions
.iter()
.map(|session| {
let id = session.get("id").and_then(Value::as_str).unwrap_or("?");
let status = session.get("status").and_then(Value::as_str).unwrap_or("?");
let provider = session
.get("provider")
.and_then(Value::as_str)
.unwrap_or("?");
let model = session.get("model").and_then(Value::as_str).unwrap_or("?");
let created = session
.get("createdAt")
.and_then(Value::as_str)
.unwrap_or("?");
let status_style = if status == "verified" {
Style::default().fg(Color::Green)
} else if status == "failed" {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::Gray)
};
ListItem::new(Line::from(vec![
Span::raw(format!("{id} ")),
Span::styled(status, status_style),
Span::raw(format!(" {provider} {model} {created}")),
]))
})
.collect();
frame.render_widget(
List::new(items)
.block(Block::default().borders(Borders::ALL).title("Sessions"))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("> "),
area,
);
}
fn draw_detail(state: &ClientState, frame: &mut Frame, area: ratatui::layout::Rect) {
let text = if let Some(error) = &state.detail_error {
Line::from(Span::styled(
format!("cannot load session: {error}"),
Style::default().fg(Color::Red),
))
} else if let Some(detail) = &state.detail {
Line::from(serde_json::to_string_pretty(detail).unwrap_or_else(|_| "?".to_owned()))
} else {
Line::from("select a session in the Sessions screen")
};
frame.render_widget(
Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title("Session detail"),
)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_run_task(state: &ClientState, frame: &mut Frame, area: ratatui::layout::Rect) {
let contract_focus = if state.focus_contract { ">" } else { " " };
let policy_focus = if state.focus_contract { " " } else { ">" };
let mut lines = vec![
Line::from(Span::styled(
format!("{contract_focus} contract path: {}", state.contract),
if state.focus_contract {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
},
)),
Line::from(Span::styled(
format!(
"{policy_focus} policy (blank = daemon default): {}",
state.policy
),
if state.focus_contract {
Style::default()
} else {
Style::default().add_modifier(Modifier::BOLD)
},
)),
Line::from(format!(
"trust workspace: {}",
if state.trust_workspace { "yes" } else { "no" }
)),
];
if state.running {
lines.push(Line::from(Span::styled(
"running proof-gated task… (q is disabled until it finishes)",
Style::default().fg(Color::Cyan),
)));
} else if let Some(result) = &state.run_result {
match result {
Ok(value) => {
let outcome = value.get("outcome").and_then(Value::as_str).unwrap_or("?");
let style = if outcome == "Verified" {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Yellow)
};
lines.push(Line::from(Span::styled(
format!(
"task finished: {outcome} sessionId={}",
value
.get("sessionId")
.and_then(Value::as_str)
.unwrap_or("?")
),
style,
)));
if let Some(proof) = value.get("proofHash").and_then(Value::as_str) {
lines.push(Line::from(format!("proofHash: {proof}")));
}
}
Err(error) => {
lines.push(Line::from(Span::styled(
format!("task failed: {error}"),
Style::default().fg(Color::Red),
)));
}
}
}
frame.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title("Run proof-gated task"),
)
.wrap(Wrap { trim: false }),
area,
);
}