use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Margin, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
use crate::events::Stage;
use crate::history::Cost;
use crate::tui::app::App;
use crate::tui::auto::{Chain, Mark};
use crate::tui::cost::rows_cost;
use crate::tui::format::hang;
use crate::tui::motion::Bar;
use crate::tui::pipeline::{PipelineView, STAGES, StageState, command_name};
use crate::tui::theme::Theme;
use crate::tui::widgets::bar::bar;
pub(in crate::tui) fn render(frame: &mut Frame, area: Rect, app: &mut App) {
let theme = &app.theme;
let view = &app.pipeline;
let area = area.inner(Margin::new(2, 0));
let [title_row, rows, chain_row, rest] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(4),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(area);
let title = match view.command {
Some(command) => format!("Pipeline · {}", command_name(command)),
None => "Pipeline".to_string(),
};
frame.render_widget(Paragraph::new(Span::styled(title, theme.title)), title_row);
let [.., counts] = columns(title_row);
frame.render_widget(
Paragraph::new(Span::styled(" in flight retries failed", theme.dim)),
counts,
);
let row_areas = Layout::vertical([Constraint::Length(1); 4]).split(rows);
for (index, (stage, row_area)) in STAGES.iter().zip(row_areas.iter()).enumerate() {
let row = view.row(*stage);
let [_, _, bar_area, ..] = columns(*row_area);
let shown = app
.motion
.bar(Bar::Stage(index), row.ratio(), bar_area.width);
let glyph = app.motion.spinner();
render_row(frame, *row_area, (view, *stage), (glyph, shown), theme);
}
if let Some(chain) = &app.auto.chain {
frame.render_widget(Paragraph::new(chain_line(chain, theme)), chain_row);
}
let mut errors = view.errors.len();
let paragraph = loop {
let lines = details(view, theme, errors, rest.width);
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
if errors == 0 || paragraph.line_count(rest.width) <= usize::from(rest.height) {
break paragraph;
}
errors -= 1;
};
frame.render_widget(paragraph, rest);
}
fn chain_line(chain: &Chain, theme: &Theme) -> Line<'static> {
let mut spans = vec![Span::styled("auto ", theme.title)];
for (at, (name, mark)) in chain.links().into_iter().enumerate() {
if at > 0 {
spans.push(Span::styled(" → ", theme.dim));
}
let (glyph, style) = match mark {
Mark::Pending => ("", theme.dim),
Mark::Running => (" ●", theme.accent),
Mark::Done => (" ✓", theme.ok),
Mark::Stopped => (" ✗", theme.error),
};
let name_style = if mark == Mark::Pending {
theme.dim
} else {
Style::new()
};
spans.push(Span::styled(name, name_style));
spans.push(Span::styled(glyph, style));
}
Line::from(spans)
}
fn columns(area: Rect) -> [Rect; 5] {
Layout::horizontal([
Constraint::Length(2),
Constraint::Length(12),
Constraint::Fill(1),
Constraint::Length(10),
Constraint::Length(27),
])
.areas(area)
}
fn render_row(
frame: &mut Frame,
area: Rect,
(view, stage): (&PipelineView, Stage),
(spinner, shown): (&str, f64),
theme: &Theme,
) {
let [glyph_area, name_area, bar_area, label_area, counts_area] = columns(area);
let row = view.row(stage);
let (glyph, glyph_style, name_style) = match row.state {
StageState::Idle => (" ", theme.dim, Style::new()),
StageState::Pending => ("·", theme.dim, theme.dim),
StageState::Running => (spinner, theme.accent, theme.accent),
StageState::Done => ("✓", theme.ok, Style::new()),
StageState::Stopped => ("✗", theme.error, Style::new()),
};
frame.render_widget(Paragraph::new(Span::styled(glyph, glyph_style)), glyph_area);
frame.render_widget(
Paragraph::new(Span::styled(stage.to_string(), name_style)),
name_area,
);
if row.state == StageState::Pending {
frame.render_widget(Paragraph::new(Span::styled("pending", theme.dim)), bar_area);
}
if !matches!(
row.state,
StageState::Running | StageState::Done | StageState::Stopped
) {
return;
}
let filled = bar(shown, bar_area.width);
let mut spans = vec![Span::styled(filled.trim_end().to_string(), theme.gauge)];
if row.state == StageState::Stopped {
let room = filled.chars().count() - filled.trim_end().chars().count();
if room > " stopped".len() {
spans.push(Span::styled(" stopped", theme.error));
}
}
frame.render_widget(Paragraph::new(Line::from(spans)), bar_area);
frame.render_widget(
Paragraph::new(format!("{}/{}", row.finished, row.total)).right_aligned(),
label_area,
);
frame.render_widget(
Paragraph::new(format!(
" {:>9} {:>7} {:>6}",
view.in_flight(stage),
row.retries(),
row.failed
)),
counts_area,
);
}
fn details(view: &PipelineView, theme: &Theme, errors: usize, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if view.command.is_none() {
lines.push(Line::from(Span::styled(
"Nothing ran yet in this TUI: press r to run a stage.",
theme.dim,
)));
return lines;
}
let usage = view.usage();
let cost = match rows_cost(&view.rows) {
Cost::Unknown => "cost unknown".to_string(),
cost => format!("cost {cost}"),
};
lines.push(Line::from(format!(
"tokens in {} out {} {cost}",
usage.input_tokens, usage.output_tokens
)));
if view.skipped > 0 {
lines.push(Line::from(Span::styled(
format!(
"{} events skipped: counts catch up when each stage finishes",
view.skipped
),
theme.warn,
)));
}
if errors > 0 {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Last errors", theme.title)));
let older = view.errors.len().saturating_sub(errors);
for failure in view.errors.iter().skip(older) {
let text = format!(
"{} {} {}",
failure.stage,
short(&failure.id),
failure.error
);
lines.extend(hang(&text, width, 2).into_iter().map(Line::from));
}
}
if !view.results.is_empty() || view.outcome.is_some() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Results", theme.title)));
}
for result in &view.results {
lines.extend(hang(result, width, 2).into_iter().map(Line::from));
}
if let Some(Err(error)) = &view.outcome {
let wrapped = hang(&format!("✗ {error}"), width, 2);
lines.extend(
wrapped
.into_iter()
.map(|line| Line::from(Span::styled(line, theme.error))),
);
}
lines
}
fn short(id: &str) -> String {
if id.len() == 32 && id.chars().all(|c| c.is_ascii_hexdigit()) {
format!("{}…{}", &id[..4], &id[28..])
} else {
id.to_string()
}
}