use color_eyre::Result;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState};
use super::Component;
use crate::action::Action;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConversationRow {
pub id: String,
pub model: String,
pub principal_ref: String,
pub phase: Option<String>,
pub pod_ip: Option<String>,
pub harness_ready: bool,
pub closed: bool,
pub committed_turns: Option<usize>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub pending_approvals: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum SortKey {
#[default]
Id,
Phase,
Tokens,
Pending,
}
impl SortKey {
#[must_use]
const fn next(self) -> Self {
match self {
Self::Id => Self::Phase,
Self::Phase => Self::Tokens,
Self::Tokens => Self::Pending,
Self::Pending => Self::Id,
}
}
const fn label(self) -> &'static str {
match self {
Self::Id => "id",
Self::Phase => "phase",
Self::Tokens => "tokens",
Self::Pending => "pending",
}
}
}
#[derive(Default)]
pub(crate) struct Fleet {
pub rows: Vec<ConversationRow>,
pub selected: Option<usize>,
sort: SortKey,
table_state: TableState,
}
impl Fleet {
fn set_rows(&mut self, rows: Vec<ConversationRow>) {
let prev_id = self.selected_id();
self.rows = rows;
self.apply_sort();
self.restore_selection(prev_id.as_deref());
}
fn upsert(&mut self, row: ConversationRow) {
let prev_id = self.selected_id();
if let Some(existing) = self.rows.iter_mut().find(|r| r.id == row.id) {
*existing = row;
} else {
self.rows.push(row);
}
self.apply_sort();
self.restore_selection(prev_id.as_deref());
}
fn selected_id(&self) -> Option<String> {
self.selected
.and_then(|i| self.rows.get(i))
.map(|r| r.id.clone())
}
fn restore_selection(&mut self, id: Option<&str>) {
let idx = id.and_then(|id| self.rows.iter().position(|r| r.id == id));
self.selected = idx.or_else(|| (!self.rows.is_empty()).then_some(0));
self.table_state.select(self.selected);
}
fn apply_sort(&mut self) {
match self.sort {
SortKey::Id => self.rows.sort_by(|a, b| a.id.cmp(&b.id)),
SortKey::Phase => self
.rows
.sort_by(|a, b| a.phase.cmp(&b.phase).then_with(|| a.id.cmp(&b.id))),
SortKey::Tokens => self.rows.sort_by(|a, b| {
token_burn(b)
.cmp(&token_burn(a))
.then_with(|| a.id.cmp(&b.id))
}),
SortKey::Pending => self.rows.sort_by(|a, b| {
b.pending_approvals
.unwrap_or(0)
.cmp(&a.pending_approvals.unwrap_or(0))
.then_with(|| a.id.cmp(&b.id))
}),
}
}
fn move_cursor(&mut self, delta: isize) {
let next = super::step_selection(self.selected, self.rows.len(), delta);
self.selected = next;
self.table_state.select(next);
}
}
fn token_burn(r: &ConversationRow) -> u64 {
r.input_tokens.unwrap_or(0) + r.output_tokens.unwrap_or(0)
}
fn traffic_light(r: &ConversationRow) -> (char, Color) {
let phase = r.phase.as_deref().unwrap_or("");
let phase_bad = {
let p = phase.to_ascii_lowercase();
p.contains("error") || p.contains("fail")
};
if r.closed || phase_bad {
('\u{25CF}', Color::Red)
} else if r.harness_ready || phase == "event-log" {
('\u{25CF}', Color::Green)
} else {
('\u{25CF}', Color::Yellow)
}
}
impl Component for Fleet {
fn controls(&self) -> Vec<(&'static str, &'static str)> {
vec![("↑↓/jk", "move"), ("Enter", "open"), ("s", "sort")]
}
fn handle(&mut self, action: &Action) -> Option<Action> {
match action {
Action::FleetLoaded(rows) => {
self.set_rows(rows.clone());
None
}
Action::FleetDelta(row) => {
self.upsert(row.clone());
None
}
Action::Key(KeyEvent { code, .. }) => match code {
KeyCode::Up | KeyCode::Char('k') => {
self.move_cursor(-1);
None
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_cursor(1);
None
}
KeyCode::Char('s') => {
self.sort = self.sort.next();
let keep = self.selected_id();
self.apply_sort();
self.restore_selection(keep.as_deref());
None
}
KeyCode::Enter => {
self.selected_id().map(Action::Select)
}
_ => None,
},
Action::Select(id) => {
if let Some(i) = self.rows.iter().position(|r| &r.id == id) {
self.selected = Some(i);
self.table_state.select(Some(i));
}
None
}
_ => None,
}
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let title = format!(
" fleet \u{2014} {} conv \u{2014} sort: {} (s) ",
self.rows.len(),
self.sort.label()
);
let block = Block::default().title(title).borders(Borders::ALL);
if self.rows.is_empty() {
frame.render_widget(block, area);
return Ok(());
}
let header = Row::new(vec![
Cell::from(""),
Cell::from("id"),
Cell::from("phase"),
Cell::from("turn"),
Cell::from("tokens (in/out)"),
Cell::from("apprv"),
Cell::from("pod"),
Cell::from("model"),
])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED));
let table_rows: Vec<Row> = self
.rows
.iter()
.map(|r| {
let (dot, color) = traffic_light(r);
let phase = r.phase.clone().unwrap_or_else(|| "-".to_string());
let turn = if r.closed {
"closed".to_string()
} else if r.harness_ready {
"live".to_string()
} else if r.phase.as_deref() == Some("event-log") {
"log".to_string()
} else {
"wait".to_string()
};
let tokens = match (r.input_tokens, r.output_tokens) {
(Some(i), Some(o)) => format!("{i}/{o}"),
_ => "-".to_string(),
};
let apprv = match r.pending_approvals {
Some(0) | None => "-".to_string(),
Some(n) => n.to_string(),
};
let pod = r.pod_ip.clone().unwrap_or_else(|| "-".to_string());
let apprv_cell = if matches!(r.pending_approvals, Some(n) if n > 0) {
Cell::from(apprv).style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else {
Cell::from(apprv)
};
Row::new(vec![
Cell::from(Line::from(Span::styled(
dot.to_string(),
Style::default().fg(color),
))),
Cell::from(r.id.clone()),
Cell::from(phase),
Cell::from(turn),
Cell::from(tokens),
apprv_cell,
Cell::from(pod),
Cell::from(r.model.clone()),
])
})
.collect();
let widths = [
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(9),
Constraint::Length(7),
Constraint::Length(16),
Constraint::Length(6),
Constraint::Length(16),
Constraint::Min(8),
];
let table = Table::new(table_rows, widths)
.header(header)
.block(block)
.row_highlight_style(
Style::default()
.bg(Color::Indexed(236))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
self.table_state.select(self.selected);
frame.render_stateful_widget(table, area, &mut self.table_state);
Ok(())
}
}