use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table};
use super::{App, Focus, JobStatus, Screen};
use crate::util::format_sol_compact;
const FG: Color = Color::Reset;
fn muted() -> Style {
Style::default().add_modifier(Modifier::DIM)
}
const ACCENT: Color = Color::Cyan;
const OK: Color = Color::Green;
const WARN: Color = Color::Yellow;
const ERR: Color = Color::Red;
pub fn render(f: &mut Frame, app: &mut App) {
match app.screen {
Screen::Main => render_main(f, app),
Screen::JobDetail(idx) => render_detail(f, app, idx),
Screen::Recovery => render_recovery(f, app),
}
}
fn render_main(f: &mut Frame, app: &mut App) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Percentage(50), Constraint::Min(4), Constraint::Length(1), ])
.split(area);
let price_str = if app.price == 0 {
"FREE".to_string()
} else {
format!("{} SOL", format_sol_compact(app.price))
};
let header_line1 = Line::from(vec![
Span::styled(" ⚡ ELISYM", Style::default().fg(WARN).bold()),
Span::styled(" agent: ", muted()),
Span::styled(&app.agent_name, Style::default().fg(FG).bold()),
Span::styled(" skill: ", muted()),
Span::styled(&app.skill_name, Style::default().fg(ACCENT).bold()),
]);
let header_line2 = Line::from(vec![
Span::styled(" price: ", muted()),
Span::styled(
&price_str,
if app.price == 0 {
Style::default().fg(WARN).bold()
} else {
Style::default().fg(OK).bold()
},
),
Span::styled(" wallet: ", muted()),
Span::styled(
format!("{} SOL", format_sol_compact(app.wallet_balance)),
Style::default().fg(OK),
),
Span::styled(" ", Style::default()),
Span::styled(&app.network, Style::default().fg(ACCENT)),
]);
let header = Paragraph::new(vec![header_line1, header_line2]);
f.render_widget(header, chunks[0]);
let table_focus = matches!(app.focus, Focus::Table);
let table_border_style = if table_focus {
Style::default().fg(ACCENT)
} else {
muted()
};
let job_count = app.jobs.len();
let title = if job_count == 0 {
" Jobs ".to_string()
} else {
let running = app.jobs.iter().filter(|j| matches!(j.status, JobStatus::Processing)).count();
let done = app.jobs.iter().filter(|j| matches!(j.status, JobStatus::Completed)).count();
let failed = app.jobs.iter().filter(|j| matches!(j.status, JobStatus::Failed(_))).count();
format!(" Jobs ({}) — {} running, {} done, {} failed ", job_count, running, done, failed)
};
let header_row = Row::new(vec![
Cell::from(" # "),
Cell::from("Job ID"),
Cell::from("From"),
Cell::from("Status"),
Cell::from("Skill"),
Cell::from(" Time"),
Cell::from(" SOL"),
])
.style(Style::default().bold().fg(FG))
.bottom_margin(0);
let rows: Vec<Row> = app
.jobs
.iter()
.enumerate()
.map(|(i, job)| {
let short_id = if job.job_id.len() > 10 {
format!("{}…", &job.job_id[..10])
} else {
job.job_id.clone()
};
let short_customer = if job.customer_id.len() > 10 {
format!("{}…", &job.customer_id[..10])
} else {
job.customer_id.clone()
};
let elapsed = job
.completed_at
.unwrap_or_else(std::time::Instant::now)
.duration_since(job.started_at);
let secs = elapsed.as_secs();
let time_str = if secs >= 60 {
format!("{:>2}m{:02}s", secs / 60, secs % 60)
} else {
format!("{:>4}s", secs)
};
let sol_str = job
.price
.map(format_sol_compact)
.unwrap_or_else(|| " --".into());
let (status_text, status_style) = match &job.status {
JobStatus::PaymentPending => ("$ Awaiting", Style::default().fg(WARN)),
JobStatus::Processing => ("⚙ Running", Style::default().fg(ACCENT)),
JobStatus::Completed => ("✓ Done", Style::default().fg(OK)),
JobStatus::Failed(_) => ("✗ Failed", Style::default().fg(ERR)),
};
let skill_str = job.skill_name.as_deref().unwrap_or("—");
let row_style = if i % 2 == 1 {
muted()
} else {
Style::default()
};
Row::new(vec![
Cell::from(format!("{:>2}", i + 1)).style(muted()),
Cell::from(short_id).style(Style::default().fg(FG)),
Cell::from(short_customer).style(muted()),
Cell::from(status_text).style(status_style),
Cell::from(skill_str).style(Style::default().fg(ACCENT)),
Cell::from(time_str).style(muted()),
Cell::from(format!("{:>7}", sol_str)).style(Style::default().fg(WARN)),
])
.style(row_style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(4), Constraint::Min(14), Constraint::Min(14), Constraint::Length(11), Constraint::Length(18), Constraint::Length(7), Constraint::Length(9), ],
)
.header(header_row)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(table_border_style)
.title(title),
)
.row_highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(table, chunks[1], &mut app.table_state);
if app.jobs.is_empty() {
let inner = chunks[1].inner(Margin::new(1, 1));
let empty = Paragraph::new(" Waiting for jobs…")
.style(muted().add_modifier(Modifier::ITALIC))
.alignment(Alignment::Left);
if inner.height > 2 {
let empty_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: 1,
};
f.render_widget(empty, empty_area);
}
}
let log_focus = matches!(app.focus, Focus::Log);
let log_border_style = if log_focus {
Style::default().fg(ACCENT)
} else {
muted()
};
let log_lines: Vec<Line> = app
.global_logs
.iter()
.map(|l| {
Line::from(vec![
Span::styled(format!(" {} ", l.time), muted()),
Span::styled(format!("{} ", l.icon), icon_style(l.icon)),
Span::styled(&l.message, Style::default().fg(FG)),
])
})
.collect();
let log_height = chunks[2].height.saturating_sub(2) as usize;
let total_lines = log_lines.len();
let max_scroll = total_lines.saturating_sub(log_height) as u16;
if !log_focus {
app.log_scroll = max_scroll;
}
if app.log_scroll > max_scroll {
app.log_scroll = max_scroll;
}
let log_paragraph = Paragraph::new(log_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(log_border_style)
.title(" Log "),
)
.scroll((app.log_scroll, 0));
f.render_widget(log_paragraph, chunks[2]);
if total_lines > log_height {
let mut scrollbar_state = ScrollbarState::new(max_scroll as usize)
.position(app.log_scroll as usize);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
chunks[2],
&mut scrollbar_state,
);
}
let sound_label = if app.sound_enabled { "sound:on" } else { "sound:off" };
let help = Line::from(vec![
Span::styled(" ↑↓", Style::default().fg(FG).bold()),
Span::styled(" select ", muted()),
Span::styled("Enter", Style::default().fg(FG).bold()),
Span::styled(" detail ", muted()),
Span::styled("Tab", Style::default().fg(FG).bold()),
Span::styled(" switch pane ", muted()),
Span::styled("r", Style::default().fg(FG).bold()),
Span::styled(" recovery ", muted()),
Span::styled("s", Style::default().fg(FG).bold()),
Span::styled(
format!(" {} ", sound_label),
if app.sound_enabled {
Style::default().fg(OK)
} else {
muted()
},
),
Span::styled("q", Style::default().fg(FG).bold()),
Span::styled(" quit", muted()),
]);
f.render_widget(Paragraph::new(help), chunks[3]);
}
fn render_detail(f: &mut Frame, app: &mut App, job_idx: usize) {
let area = f.area();
let job = match app.jobs.get(job_idx) {
Some(j) => j,
None => {
app.screen = Screen::Main;
return;
}
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(9), Constraint::Min(5), Constraint::Length(1), ])
.split(area);
let elapsed = job
.completed_at
.unwrap_or_else(std::time::Instant::now)
.duration_since(job.started_at);
let price_str = job
.price
.map(|p| format!("{} SOL", format_sol_compact(p)))
.unwrap_or_else(|| "—".into());
let net_str = job
.net_amount
.map(|n| format!(" (net: {} SOL)", format_sol_compact(n)))
.unwrap_or_default();
let input_preview = if job.input.len() > 60 {
format!("{}…", &job.input[..60])
} else {
job.input.clone()
};
let input_preview = input_preview.replace('\n', " ");
let secs = elapsed.as_secs();
let duration_str = if secs >= 60 {
format!("{}m{}s", secs / 60, secs % 60)
} else {
format!("{}s", secs)
};
let info_text = vec![
Line::from(vec![
Span::styled(" From: ", muted()),
Span::styled(&job.customer_id, Style::default().fg(FG)),
]),
Line::from(vec![
Span::styled(" Status: ", muted()),
Span::styled(
job.status.to_string(),
match &job.status {
JobStatus::PaymentPending => Style::default().fg(WARN),
JobStatus::Processing => Style::default().fg(ACCENT),
JobStatus::Completed => Style::default().fg(OK),
JobStatus::Failed(_) => Style::default().fg(ERR),
},
),
]),
Line::from(vec![
Span::styled(" Skill: ", muted()),
Span::styled(
job.skill_name.as_deref().unwrap_or("—"),
Style::default().fg(ACCENT),
),
]),
Line::from(vec![
Span::styled(" Input: ", muted()),
Span::styled(input_preview, Style::default().fg(FG)),
]),
Line::from(vec![
Span::styled(" Price: ", muted()),
Span::styled(
format!("{}{}", price_str, net_str),
Style::default().fg(WARN),
),
]),
Line::from(vec![
Span::styled(" Duration: ", muted()),
Span::styled(duration_str, Style::default().fg(FG)),
]),
];
let short_id = if job.job_id.len() > 16 {
format!("{}…", &job.job_id[..16])
} else {
job.job_id.clone()
};
let info = Paragraph::new(info_text).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ACCENT))
.title(format!(" Job {} ", short_id))
.title_bottom(" Esc to back "),
);
f.render_widget(info, chunks[0]);
let detail_lines: Vec<Line> = job
.logs
.iter()
.map(|l| {
Line::from(vec![
Span::styled(format!(" {} ", l.time), muted()),
Span::styled(format!("{} ", l.icon), icon_style(l.icon)),
Span::styled(&l.message, Style::default().fg(FG)),
])
})
.collect();
let log_height = chunks[1].height.saturating_sub(2) as usize;
let total_lines = detail_lines.len();
let max_scroll = total_lines.saturating_sub(log_height) as u16;
if app.detail_scroll > max_scroll {
app.detail_scroll = max_scroll;
}
let detail_log = Paragraph::new(detail_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ACCENT))
.title(" Events "),
)
.scroll((app.detail_scroll, 0));
f.render_widget(detail_log, chunks[1]);
if total_lines > log_height {
let mut scrollbar_state = ScrollbarState::new(max_scroll as usize)
.position(app.detail_scroll as usize);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
chunks[1],
&mut scrollbar_state,
);
}
let help = Line::from(vec![
Span::styled(" Esc", Style::default().fg(FG).bold()),
Span::styled(" back ", muted()),
Span::styled("↑↓", Style::default().fg(FG).bold()),
Span::styled(" scroll", muted()),
]);
f.render_widget(Paragraph::new(help), chunks[2]);
}
fn render_recovery(f: &mut Frame, app: &mut App) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Percentage(40), Constraint::Min(5), Constraint::Length(1), ])
.split(area);
let pending = app.recovery_entries.iter()
.filter(|e| e.status == crate::ledger::LedgerStatus::Paid || e.status == crate::ledger::LedgerStatus::Executed)
.count();
let title = Line::from(vec![
Span::styled(" ⚡RECOVERY LEDGER", Style::default().fg(WARN).bold()),
Span::styled(format!(" {} total, {} pending", app.recovery_entries.len(), pending), muted()),
]);
f.render_widget(Paragraph::new(title), chunks[0]);
let header_row = Row::new(vec![
Cell::from(" # "),
Cell::from("Job ID"),
Cell::from("Status"),
Cell::from("Retries"),
Cell::from("Customer"),
Cell::from("Age"),
])
.style(Style::default().bold().fg(FG))
.bottom_margin(0);
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let rows: Vec<Row> = app
.recovery_entries
.iter()
.enumerate()
.map(|(i, entry)| {
let short_id = if entry.job_id.len() > 12 {
format!("{}…", &entry.job_id[..12])
} else {
entry.job_id.clone()
};
let short_customer = if entry.customer_id.len() > 12 {
format!("{}…", &entry.customer_id[..12])
} else {
entry.customer_id.clone()
};
let (status_text, status_style) = match &entry.status {
crate::ledger::LedgerStatus::Paid => ("$ Paid", Style::default().fg(WARN)),
crate::ledger::LedgerStatus::Executed => ("⚙ Executed", Style::default().fg(ACCENT)),
crate::ledger::LedgerStatus::Delivered => ("✓ Delivered", Style::default().fg(OK)),
crate::ledger::LedgerStatus::Failed => ("✗ Failed", Style::default().fg(ERR)),
};
let age_secs = now_secs.saturating_sub(entry.created_at);
let age_str = if age_secs >= 86400 {
format!("{}d", age_secs / 86400)
} else if age_secs >= 3600 {
format!("{}h", age_secs / 3600)
} else if age_secs >= 60 {
format!("{}m", age_secs / 60)
} else {
format!("{}s", age_secs)
};
let row_style = if i % 2 == 1 { muted() } else { Style::default() };
Row::new(vec![
Cell::from(format!("{:>2}", i + 1)).style(muted()),
Cell::from(short_id).style(Style::default().fg(FG)),
Cell::from(status_text).style(status_style),
Cell::from(format!("{:>4}", entry.retry_count)).style(
if entry.retry_count > 0 { Style::default().fg(WARN) } else { muted() }
),
Cell::from(short_customer).style(muted()),
Cell::from(format!("{:>5}", age_str)).style(muted()),
])
.style(row_style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(4), Constraint::Min(16), Constraint::Length(12), Constraint::Length(8), Constraint::Min(16), Constraint::Length(7), ],
)
.header(header_row)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ACCENT))
.title(" Ledger entries "),
)
.row_highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(table, chunks[1], &mut app.recovery_table_state);
if app.recovery_entries.is_empty() {
let inner = chunks[1].inner(Margin::new(1, 1));
let empty = Paragraph::new(" No ledger entries — no paid jobs recorded yet.")
.style(muted().add_modifier(Modifier::ITALIC))
.alignment(Alignment::Left);
if inner.height > 2 {
let empty_area = Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: 1,
};
f.render_widget(empty, empty_area);
}
}
let detail_lines: Vec<Line> = if let Some(idx) = app.recovery_table_state.selected() {
if let Some(entry) = app.recovery_entries.get(idx) {
let status_str = match &entry.status {
crate::ledger::LedgerStatus::Paid => "Paid (awaiting execution)",
crate::ledger::LedgerStatus::Executed => "Executed (awaiting delivery)",
crate::ledger::LedgerStatus::Delivered => "Delivered (completed)",
crate::ledger::LedgerStatus::Failed => "Failed (gave up)",
};
let age_secs = now_secs.saturating_sub(entry.created_at);
let age_str = if age_secs >= 86400 {
format!("{}d {}h ago", age_secs / 86400, (age_secs % 86400) / 3600)
} else if age_secs >= 3600 {
format!("{}h {}m ago", age_secs / 3600, (age_secs % 3600) / 60)
} else {
format!("{}m ago", age_secs / 60)
};
let net_sol = crate::util::format_sol_compact(entry.net_amount);
let input_preview = if entry.input.len() > 80 {
format!("{}…", &entry.input[..80])
} else {
entry.input.clone()
};
let input_preview = input_preview.replace('\n', " ");
let has_result = entry.result.as_ref().map(|r| format!("yes ({} chars)", r.len())).unwrap_or_else(|| "no".into());
let mut lines = vec![
Line::from(vec![
Span::styled(" Job ID: ", muted()),
Span::styled(&entry.job_id, Style::default().fg(FG)),
]),
Line::from(vec![
Span::styled(" Customer: ", muted()),
Span::styled(&entry.customer_id, Style::default().fg(FG)),
]),
Line::from(vec![
Span::styled(" Status: ", muted()),
Span::styled(status_str, match &entry.status {
crate::ledger::LedgerStatus::Paid => Style::default().fg(WARN),
crate::ledger::LedgerStatus::Executed => Style::default().fg(ACCENT),
crate::ledger::LedgerStatus::Delivered => Style::default().fg(OK),
crate::ledger::LedgerStatus::Failed => Style::default().fg(ERR),
}),
]),
Line::from(vec![
Span::styled(" Net SOL: ", muted()),
Span::styled(format!("{} SOL", net_sol), Style::default().fg(WARN)),
]),
Line::from(vec![
Span::styled(" Retries: ", muted()),
Span::styled(format!("{}/5", entry.retry_count), if entry.retry_count >= 5 {
Style::default().fg(ERR)
} else {
Style::default().fg(FG)
}),
]),
Line::from(vec![
Span::styled(" Created: ", muted()),
Span::styled(age_str, Style::default().fg(FG)),
]),
Line::from(vec![
Span::styled(" Result: ", muted()),
Span::styled(has_result, Style::default().fg(FG)),
]),
Line::from(vec![
Span::styled(" Input: ", muted()),
Span::styled(input_preview, Style::default().fg(FG)),
]),
];
if !entry.tags.is_empty() {
lines.push(Line::from(vec![
Span::styled(" Tags: ", muted()),
Span::styled(entry.tags.join(", "), Style::default().fg(ACCENT)),
]));
}
lines
} else {
vec![Line::from(Span::styled(" Select an entry above", muted()))]
}
} else {
vec![Line::from(Span::styled(" Select an entry above", muted()))]
};
let detail = Paragraph::new(detail_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ACCENT))
.title(" Detail "),
)
.scroll((app.recovery_detail_scroll, 0));
f.render_widget(detail, chunks[2]);
let help = Line::from(vec![
Span::styled(" Esc", Style::default().fg(FG).bold()),
Span::styled(" back ", muted()),
Span::styled("↑↓", Style::default().fg(FG).bold()),
Span::styled(" select ", muted()),
Span::styled("Enter", Style::default().fg(FG).bold()),
Span::styled(" retry ", muted()),
Span::styled("r", Style::default().fg(FG).bold()),
Span::styled(" refresh", muted()),
]);
f.render_widget(Paragraph::new(help), chunks[3]);
}
fn icon_style(icon: &str) -> Style {
match icon {
"▶" => Style::default().fg(ACCENT),
"$" => Style::default().fg(WARN),
"✓" => Style::default().fg(OK),
"✗" => Style::default().fg(ERR),
"⚙" => Style::default().fg(ACCENT),
"↻" => Style::default().fg(WARN),
"→" | "←" | "↔" => muted(),
_ => Style::default(),
}
}