polyc-tui 2026.8.3

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! Fleet pane: the list of conversations and their lifecycle status.
//!
//! [`ConversationRow`] is the FROZEN view type the fleet adapter
//! ([`crate::data::fleet`]) produces and this component renders. Its fields
//! mirror the authoritative kube `Conversation` CR status
//! (`polyc_controller::ConversationStatus`) enriched with optional
//! forensics metrics.
//!
//! This is the home pane: it renders a sortable table (phase, mid-turn flag,
//! token burn, harness pod, and a lifecycle traffic-light) and emits
//! `Action::Select` + `Action::Nav(Pane::Transcript)` when a row is chosen.

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;

/// One row in the fleet list — a single conversation.
///
/// Lifecycle fields come from the kube `Conversation` CR (authoritative;
/// `phase`/`pod_ip`/`harness_ready`/`closed`). Metric fields are optional
/// enrichment polled from the forensics server when reachable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConversationRow {
    /// Bare conversation id (e.g. `synth-1`, `alice`) — the key used across panes.
    pub id: String,
    /// Model configured on the `ConversationSpec`.
    pub model: String,
    /// Owning principal (`spec.principalRef` — the persona id).
    pub principal_ref: String,
    /// Human lifecycle phase from `ConversationStatus`: "Pending"/"Ready"/"Closing".
    pub phase: Option<String>,
    /// Harness pod IP once scheduled (`status.podIp`).
    pub pod_ip: Option<String>,
    /// Whether the harness reported ready (`status.harnessReady`).
    pub harness_ready: bool,
    /// Whether the conversation is closed (`status.closed`).
    pub closed: bool,
    /// Committed turn count (forensics overview; `None` if unreachable).
    pub committed_turns: Option<usize>,
    /// Total input tokens (forensics usage; `None` if unreachable).
    pub input_tokens: Option<u64>,
    /// Total output tokens (forensics usage; `None` if unreachable).
    pub output_tokens: Option<u64>,
    /// Count of currently pending approvals (forensics approvals; `None` if unreachable).
    pub pending_approvals: Option<usize>,
}

/// Columns the fleet table can be ordered by. Cycled with the `s` key.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum SortKey {
    /// Conversation id (default, lexicographic).
    #[default]
    Id,
    /// Lifecycle phase.
    Phase,
    /// Total token burn (input + output).
    Tokens,
    /// Count of pending approvals (descending — most-urgent first).
    Pending,
}

impl SortKey {
    /// The next key in the cycle, for the `s` toggle.
    #[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,
        }
    }

    /// Short label shown in the title.
    const fn label(self) -> &'static str {
        match self {
            Self::Id => "id",
            Self::Phase => "phase",
            Self::Tokens => "tokens",
            Self::Pending => "pending",
        }
    }
}

/// The fleet list component.
#[derive(Default)]
pub(crate) struct Fleet {
    /// All known conversation rows, in display order (already sorted).
    pub rows: Vec<ConversationRow>,
    /// Index of the highlighted row, if any.
    pub selected: Option<usize>,
    /// Active sort key.
    sort: SortKey,
    /// Ratatui table cursor (kept in sync with `selected`).
    table_state: TableState,
}

impl Fleet {
    /// Replace the full row set (from `Action::FleetLoaded`) and re-sort,
    /// preserving the highlighted conversation by id where possible.
    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());
    }

    /// Apply a single watch delta (`Action::FleetDelta`): upsert by id, re-sort.
    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());
    }

    /// The id of the currently highlighted row, if any.
    fn selected_id(&self) -> Option<String> {
        self.selected
            .and_then(|i| self.rows.get(i))
            .map(|r| r.id.clone())
    }

    /// Re-point the cursor at `id` after a re-sort; clamp/clear otherwise.
    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);
    }

    /// Sort `rows` in place by the active key (stable, id as tiebreaker).
    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))
            }),
        }
    }

    /// Move the cursor by `delta`, clamping to the row range.
    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);
    }
}

/// Total token burn for a row (`input + output`), treating unknowns as 0.
fn token_burn(r: &ConversationRow) -> u64 {
    r.input_tokens.unwrap_or(0) + r.output_tokens.unwrap_or(0)
}

/// A lifecycle "traffic light" derived from the authoritative CR fields.
///
/// NOTE: the frozen `ConversationRow` carries no classifier-verdict field, so
/// this light reflects harness/lifecycle health rather than a content
/// classifier:
/// - red    = closed, or phase reports an error/failure
/// - green  = harness ready and not closed
/// - yellow = scheduled/pending, harness not yet ready
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" {
        // `event-log` rows have no live CR but were genuinely served (they
        // exist in the event log), so treat them as healthy, not provisioning.
        ('\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 => {
                    // Activating a row selects the conversation and pivots to
                    // the transcript. `handle` may only return one follow-up,
                    // so we emit `Select`; the app's central router treats a
                    // fleet `Select` as the signal to also `Nav(Transcript)`.
                    self.selected_id().map(Action::Select)
                }
                _ => None,
            },
            // Keep our cursor in step if another path drives selection by id.
            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());
                // "mid-turn": harness up but the conversation isn't closed.
                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") {
                    // No live harness binding — served via the shared harness
                    // and recorded in the 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("> ");

        // Keep ratatui's cursor aligned with our model before rendering.
        self.table_state.select(self.selected);
        frame.render_stateful_widget(table, area, &mut self.table_state);
        Ok(())
    }
}