#![allow(clippy::doc_overindented_list_items)]
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::tui::app::{AgentPhase, App, StationState};
use crate::tui::theme::{Glyphs, Motion, Palette, breathing_brightness, color_lerp};
const STATION_NAMES: [&str; 7] = [
"PLAN",
"IMPLEMENT",
"REVIEW",
"FIX",
"MERGE",
"SAT",
"LEARN",
];
const SPARKLINE_CELLS: [&str; 9] = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
pub fn render(frame: &mut Frame, area: Rect, app: &App, palette: &Palette) {
let title_text = build_title(app);
let block = Block::new()
.borders(Borders::ALL)
.border_style(Style::new().fg(palette.rule))
.title(Span::styled(
title_text,
Style::new().fg(palette.fg_2).add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
let cols = inner.width as usize;
let n = STATION_NAMES.len();
let col_width = cols / n;
let mut labels = Vec::with_capacity(n);
for (i, name) in STATION_NAMES.iter().enumerate() {
let style = station_label_style(app.chain_state.stations[i], palette);
let cell = format!("{name:^width$}", width = col_width);
labels.push(Span::styled(cell, style));
}
let elapsed_ms = if app.reduce_motion {
0
} else {
app.elapsed_ms()
};
let glitch_idx = if app.reduce_motion {
None
} else {
app.glitch.map(|(idx, _)| idx)
};
let mut glyphs = Vec::with_capacity(n);
for i in 0..n {
let st = app.chain_state.stations[i];
let style = station_glyph_style(st, palette, elapsed_ms, glitch_idx == Some(i));
let glyph_str: String = if glitch_idx == Some(i) {
let frame = (elapsed_ms / 30) % 4;
["▓", "▒", "░", "▞"][frame as usize].into()
} else if matches!(st, StationState::Active) && !app.reduce_motion {
let frame = (elapsed_ms / 100) as usize % Glyphs::SPINNER.len();
Glyphs::SPINNER[frame].into()
} else {
st.glyph().into()
};
let cell = format!("{:^width$}", glyph_str, width = col_width);
glyphs.push(Span::styled(cell, style));
}
let mut elapsed_row = Vec::with_capacity(n);
for i in 0..n {
let t: String = match app.chain_state.stations[i] {
StationState::Active => {
if let Some(hb) = &app.agent_heartbeat {
format_short_duration(hb.elapsed_secs * 1000)
} else {
"…".into()
}
}
_ => match app.chain_state.elapsed_ms[i] {
Some(ms) => format_short_duration(ms),
None => "—".into(),
},
};
let cell = format!("{t:^width$}", width = col_width);
elapsed_row.push(Span::styled(cell, Style::new().fg(palette.fg_3)));
}
let connector_str = "┈".repeat(inner.width as usize);
let connector = Line::from(Span::styled(connector_str, Style::new().fg(palette.fg_4)));
let scope_line = render_scope_line(app, palette, inner.width as usize);
let action_line = render_action_line(app, palette);
let lines = vec![
Line::raw(""),
Line::from(labels),
Line::from(glyphs),
Line::from(elapsed_row),
connector,
scope_line,
action_line,
];
let para = Paragraph::new(lines).style(Style::new().bg(palette.bg_0));
frame.render_widget(para, inner);
}
fn build_title(app: &App) -> String {
let Some(rid) = &app.focus_run_id else {
return " CHAIN TOPOLOGY · idle ".into();
};
let short = &rid[..8.min(rid.len())];
let info: Option<&crate::tui::app::RunInfo> = app
.runs
.iter()
.chain(app.approvals.iter())
.find(|r| r.id == *rid);
let issue = info.map(|i| i.issue.clone()).unwrap_or_else(|| "—".into());
let dur = info
.map(|i| i.duration.clone())
.unwrap_or_else(|| "—".into());
let badge = if app.terminal_card.is_some() {
" · ✓ complete"
} else if app.approvals.iter().any(|r| r.id == *rid) {
" · gate awaiting"
} else {
""
};
format!(" CHAIN TOPOLOGY · {short} · {issue} · {dur}{badge} ")
}
fn station_label_style(state: StationState, palette: &Palette) -> Style {
match state {
StationState::Active => Style::new().fg(palette.ember).add_modifier(Modifier::BOLD),
StationState::Completed => Style::new().fg(palette.fg_1).add_modifier(Modifier::BOLD),
StationState::Failed => Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
StationState::Pending => Style::new().fg(palette.fg_3),
StationState::NotApplicable => Style::new().fg(palette.fg_4),
}
}
fn station_glyph_style(
state: StationState,
palette: &Palette,
elapsed_ms: u64,
glitching: bool,
) -> Style {
if glitching {
return Style::new()
.fg(palette.red)
.add_modifier(Modifier::BOLD | Modifier::REVERSED);
}
match state {
StationState::Active => {
let b = breathing_brightness(elapsed_ms, Motion::BEAT.as_millis() as u64);
let t = ((b - 0.86) / 0.14).clamp(0.0, 1.0);
let color = color_lerp(palette.ember_dim, palette.ember, t);
Style::new().fg(color).add_modifier(Modifier::BOLD)
}
StationState::Completed => Style::new().fg(palette.state_pass),
StationState::Failed => Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
StationState::Pending => Style::new().fg(palette.fg_3),
StationState::NotApplicable => Style::new().fg(palette.fg_4),
}
}
fn render_scope_line<'a>(app: &'a App, palette: &Palette, width: usize) -> Line<'a> {
use crate::tui::app::SCOPE_WIDTH;
let phase = app.agent_phase();
let suffix = match (phase, &app.agent_heartbeat) {
(AgentPhase::Producing, Some(hb)) => {
let rate = hb.rate_per_sec();
format!(
" scope · producing · {} · +{}/s",
format_chars(hb.output_chars),
rate
)
}
(AgentPhase::Thinking, Some(hb)) => format!(
" scope · thinking · {} · last {}s",
format_chars(hb.output_chars),
hb.last_frame_at.elapsed().as_secs()
),
_ => " scope · idle".to_string(),
};
let suffix_color = match phase {
AgentPhase::Producing => palette.cyan,
AgentPhase::Thinking => palette.violet,
AgentPhase::Idle => palette.fg_3,
};
let bar_color = match phase {
AgentPhase::Producing => palette.cyan,
AgentPhase::Thinking => palette.violet,
AgentPhase::Idle => palette.fg_3,
};
let bar_width = width.saturating_sub(suffix.chars().count() + 2).max(8);
let trace: Vec<f32> = app.scope_trace.iter().copied().collect();
let mut bar = String::with_capacity(bar_width * 3);
if trace.is_empty() {
bar.push_str(&" ".repeat(bar_width));
} else {
let src_len = trace.len();
for i in 0..bar_width {
let lo = (i * src_len) / bar_width;
let hi = ((i + 1) * src_len) / bar_width;
let slice = &trace[lo..hi.max(lo + 1).min(src_len)];
let avg: f32 = if slice.is_empty() {
0.0
} else {
slice.iter().sum::<f32>() / slice.len() as f32
};
let idx = (avg * (SPARKLINE_CELLS.len() - 1) as f32).round() as usize;
let idx = idx.min(SPARKLINE_CELLS.len() - 1);
bar.push_str(SPARKLINE_CELLS[idx]);
}
}
let _ = SCOPE_WIDTH;
Line::from(vec![
Span::raw(" "),
Span::styled(bar, Style::new().fg(bar_color)),
Span::styled(suffix, Style::new().fg(suffix_color)),
])
}
fn render_action_line<'a>(app: &'a App, palette: &Palette) -> Line<'a> {
let active_idx = app
.chain_state
.stations
.iter()
.position(|s| matches!(s, StationState::Active));
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
if let Some(idx) = active_idx {
let station_name = STATION_NAMES.get(idx).copied().unwrap_or("—");
spans.push(Span::styled(
format!("▸ {station_name}"),
Style::new().fg(palette.ember).add_modifier(Modifier::BOLD),
));
if let Some(tool) = &app.last_tool {
let short_tool = if tool.chars().count() > 32 {
let mut s = tool.chars().take(30).collect::<String>();
s.push('…');
s
} else {
tool.clone()
};
spans.push(Span::styled(" ⚡ ", Style::new().fg(palette.magenta)));
spans.push(Span::styled(short_tool, Style::new().fg(palette.fg_1)));
}
if let Some(hb) = &app.agent_heartbeat {
spans.push(Span::styled(" ", Style::new()));
spans.push(Span::styled(
format_short_duration(hb.elapsed_secs * 1000),
Style::new().fg(palette.fg_2),
));
let since = hb.last_frame_at.elapsed().as_secs();
spans.push(Span::styled(
format!(" last {since}s"),
Style::new().fg(palette.fg_3),
));
}
} else if app.terminal_card.is_some() {
spans.push(Span::styled(
"✓ shift complete · all stations finished",
Style::new().fg(palette.state_pass),
));
} else if app.focus_run_id.is_some() {
spans.push(Span::styled(
"· no active station",
Style::new().fg(palette.fg_3),
));
} else {
spans.push(Span::styled(
"· no shift focused",
Style::new().fg(palette.fg_3),
));
}
Line::from(spans)
}
fn format_short_duration(ms: u64) -> String {
let s = ms / 1000;
if s < 60 {
format!("{s}s")
} else if s < 3600 {
format!("{}m{:02}s", s / 60, s % 60)
} else {
format!("{}h{:02}m", s / 3600, (s % 3600) / 60)
}
}
fn format_chars(n: u64) -> String {
if n < 1_000 {
format!("{n}B")
} else if n < 1_000_000 {
format!("{}KB", n / 1_000)
} else {
format!("{:.1}MB", n as f64 / 1_000_000.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duration_formatting() {
assert_eq!(format_short_duration(6000), "6s");
assert_eq!(format_short_duration(502_000), "8m22s");
assert_eq!(format_short_duration(3_700_000), "1h01m");
}
#[test]
fn station_names_count_is_seven() {
assert_eq!(STATION_NAMES.len(), 7);
}
#[test]
fn sparkline_cells_cover_full_range() {
assert!(SPARKLINE_CELLS.len() >= 8);
}
}