use std::io::IsTerminal as _;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use crossterm::event::EventStream;
use ratatui::crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseEventKind,
};
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::crossterm::execute;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use futures_util::StreamExt as _;
use std::sync::{Mutex, OnceLock};
const LIST_PCT: u16 = 30;
static LOG: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
fn log_buffer() -> &'static Mutex<Vec<String>> {
LOG.get_or_init(|| Mutex::new(Vec::new()))
}
pub struct LogWriter;
impl std::io::Write for LogWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let s = String::from_utf8_lossy(buf);
let line = s.trim_end_matches('\n');
if !line.is_empty() {
log_buffer().lock().unwrap().push(line.to_owned());
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogWriter {
type Writer = LogWriter;
fn make_writer(&'a self) -> Self::Writer {
LogWriter
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Status {
Running,
Finished,
Failed,
}
impl Status {
fn color(self) -> Color {
match self {
Status::Running => Color::Yellow,
Status::Finished => Color::Green,
Status::Failed => Color::Red,
}
}
fn glyph(self) -> &'static str {
match self {
Status::Running => "•",
Status::Finished => "✓",
Status::Failed => "✗",
}
}
}
#[derive(Clone, Copy)]
pub enum Source {
Orchestrator,
Worker,
}
pub enum Msg {
AddHost { name: String, tags: Vec<String> },
Line(usize, Source, String),
Status(usize, Status),
}
struct Host {
name: String,
tags: Vec<String>,
status: Status,
lines: Vec<(Source, String)>,
}
#[derive(Default)]
struct OutputScroll {
vert: u16,
horiz: u16,
}
pub async fn run(rx: tokio::sync::mpsc::UnboundedReceiver<Msg>) {
if std::io::stderr().is_terminal() {
if let Err(e) = tui(rx).await {
eprintln!("progress view error: {e:?}");
}
} else {
stream(rx).await;
}
}
fn add_host(hosts: &mut Vec<Host>, name: String, tags: Vec<String>) {
hosts.push(Host {
name,
tags,
status: Status::Running,
lines: Vec::new(),
});
}
async fn stream(mut rx: tokio::sync::mpsc::UnboundedReceiver<Msg>) {
let mut hosts: Vec<Host> = Vec::new();
while let Some(msg) = rx.recv().await {
match msg {
Msg::AddHost { name, tags } => add_host(&mut hosts, name, tags),
Msg::Line(idx, _src, line) => {
eprintln!("\x1b[36m[{}]\x1b[0m {}", hosts[idx].name, line);
}
Msg::Status(idx, status) => {
hosts[idx].status = status;
let label = match status {
Status::Finished => "\x1b[32mfinished\x1b[0m",
Status::Failed => "\x1b[31mfailed\x1b[0m",
Status::Running => continue,
};
eprintln!("[{}] {label}", hosts[idx].name);
}
}
}
}
async fn tui(mut rx: tokio::sync::mpsc::UnboundedReceiver<Msg>) -> eyre::Result<()> {
enable_raw_mode()?;
execute!(std::io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;
let mut hosts: Vec<Host> = Vec::new();
let result = tui_loop(&mut hosts, &mut rx).await;
let _ = disable_raw_mode();
let _ = execute!(std::io::stderr(), DisableMouseCapture, LeaveAlternateScreen);
result
}
async fn tui_loop(
hosts: &mut Vec<Host>,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<Msg>,
) -> eyre::Result<()> {
let backend = CrosstermBackend::new(std::io::stderr());
let mut terminal = Terminal::new(backend)?;
let mut events = EventStream::new();
let mut selected = 0usize;
let mut list_state = ListState::default();
let mut confirm_quit = false;
let mut scroll = OutputScroll::default();
let mut channel_open = true;
let mut log_height: u16 = 6;
loop {
list_state.select(Some(selected));
terminal.draw(|f| {
draw(
f,
hosts,
&mut list_state,
selected,
confirm_quit,
&mut scroll,
log_height,
)
})?;
tokio::select! {
biased;
event = events.next() => {
match event {
Some(Ok(Event::Mouse(m))) => {
let list_width = terminal.size()?.width * LIST_PCT / 100;
let over_list = m.column < list_width;
let shift = m.modifiers.contains(KeyModifiers::SHIFT);
match m.kind {
MouseEventKind::ScrollUp if over_list => {
selected = selected.saturating_sub(1);
scroll = OutputScroll::default();
}
MouseEventKind::ScrollDown if over_list => {
selected = (selected + 1).min(hosts.len().saturating_sub(1));
scroll = OutputScroll::default();
}
MouseEventKind::ScrollUp if shift => {
scroll.horiz = scroll.horiz.saturating_sub(6)
}
MouseEventKind::ScrollDown if shift => {
scroll.horiz = scroll.horiz.saturating_add(6)
}
MouseEventKind::ScrollUp => scroll.vert = scroll.vert.saturating_add(3),
MouseEventKind::ScrollDown => {
scroll.vert = scroll.vert.saturating_sub(3)
}
MouseEventKind::ScrollLeft => {
scroll.horiz = scroll.horiz.saturating_sub(6)
}
MouseEventKind::ScrollRight => {
scroll.horiz = scroll.horiz.saturating_add(6)
}
_ => {}
}
}
Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => {
if confirm_quit {
match key.code {
KeyCode::Char('y') => return Ok(()),
_ => confirm_quit = false,
}
continue;
}
match key.code {
KeyCode::Char('q') => confirm_quit = true,
KeyCode::Tab => {
selected = (selected + 1).min(hosts.len().saturating_sub(1));
scroll = OutputScroll::default();
}
KeyCode::BackTab => {
selected = selected.saturating_sub(1);
scroll = OutputScroll::default();
}
KeyCode::Up | KeyCode::Char('k') => {
scroll.vert = scroll.vert.saturating_add(1)
}
KeyCode::Down | KeyCode::Char('j') => {
scroll.vert = scroll.vert.saturating_sub(1)
}
KeyCode::PageUp => scroll.vert = scroll.vert.saturating_add(10),
KeyCode::PageDown => scroll.vert = scroll.vert.saturating_sub(10),
KeyCode::Left | KeyCode::Char('h') => {
scroll.horiz = scroll.horiz.saturating_sub(8)
}
KeyCode::Right | KeyCode::Char('l') => {
scroll.horiz = scroll.horiz.saturating_add(8)
}
KeyCode::Home => {
scroll.vert = u16::MAX;
scroll.horiz = 0;
}
KeyCode::End => scroll.vert = 0,
KeyCode::Char('+') | KeyCode::Char('=') => {
log_height = (log_height + 1).min(40)
}
KeyCode::Char('-') => log_height = log_height.saturating_sub(1),
_ => {}
}
}
_ => {}
}
}
msg = rx.recv(), if channel_open => {
match msg {
Some(Msg::AddHost { name, tags }) => add_host(hosts, name, tags),
Some(Msg::Line(idx, src, line)) => {
hosts[idx].lines.push((src, line));
if idx == selected && scroll.vert > 0 {
scroll.vert = scroll.vert.saturating_add(1);
}
}
Some(Msg::Status(idx, status)) => hosts[idx].status = status,
None => channel_open = false,
}
}
}
}
}
fn draw(
f: &mut ratatui::Frame,
hosts: &[Host],
list_state: &mut ListState,
selected: usize,
confirm_quit: bool,
scroll: &mut OutputScroll,
log_height: u16,
) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(log_height),
Constraint::Length(1),
])
.split(f.area());
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(LIST_PCT),
Constraint::Percentage(100 - LIST_PCT),
])
.split(rows[0]);
draw_list(f, panes[0], hosts, list_state, confirm_quit);
draw_output(f, panes[1], hosts.get(selected), scroll);
if log_height > 0 {
draw_log(f, rows[1]);
}
draw_legend(f, rows[2]);
}
fn draw_legend(f: &mut ratatui::Frame, area: Rect) {
let legend = Paragraph::new(Line::from(Span::styled(
" scroll: arrows/jk · pan: h/l · host: Tab · page: PgUp/Dn · top/tail: Home/End · quit: q",
Style::default().fg(Color::DarkGray),
)));
f.render_widget(legend, area);
}
fn ansi_line(s: &str) -> Line<'static> {
use ansi_to_tui::IntoText as _;
s.into_text()
.ok()
.and_then(|t| t.lines.into_iter().next())
.unwrap_or_else(|| Line::raw(s.to_owned()))
}
fn source_line(src: Source, s: &str) -> Line<'static> {
let (glyph, color) = match src {
Source::Orchestrator => ("◆ ", Color::Magenta),
Source::Worker => ("● ", Color::Cyan),
};
let mut line = ansi_line(s);
line.spans
.insert(0, Span::styled(glyph, Style::default().fg(color)));
line
}
fn draw_log(f: &mut ratatui::Frame, area: Rect) {
let buf = log_buffer().lock().unwrap();
let view = area.height.saturating_sub(2);
let start = buf.len().saturating_sub(view as usize);
let body: Vec<Line> = buf[start..].iter().map(|l| ansi_line(l)).collect();
let para = Paragraph::new(body)
.block(Block::default().borders(Borders::ALL).title(" cli log "));
f.render_widget(para, area);
}
fn draw_list(
f: &mut ratatui::Frame,
area: Rect,
hosts: &[Host],
list_state: &mut ListState,
confirm_quit: bool,
) {
let (mut running, mut done, mut failed) = (0, 0, 0);
for h in hosts {
match h.status {
Status::Running => running += 1,
Status::Finished => done += 1,
Status::Failed => failed += 1,
}
}
let items: Vec<ListItem> = hosts
.iter()
.map(|h| {
let mut spans = vec![
Span::styled(
format!("{} ", h.status.glyph()),
Style::default().fg(h.status.color()),
),
Span::raw(h.name.clone()),
];
if !h.tags.is_empty() {
spans.push(Span::styled(
format!(" {}", h.tags.join(",")),
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let title = format!(" {running} running · {done} done · {failed} failed ");
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_stateful_widget(list, area, list_state);
if confirm_quit {
let hint = Paragraph::new("quit? y/n").style(Style::default().fg(Color::Red));
let bar = Rect {
x: area.x + 1,
y: area.y + area.height.saturating_sub(1),
width: area.width.saturating_sub(2),
height: 1,
};
f.render_widget(hint, bar);
}
}
fn draw_output(f: &mut ratatui::Frame, area: Rect, host: Option<&Host>, scroll: &mut OutputScroll) {
let (name, body): (Option<&str>, Vec<Line>) = match host {
Some(h) => (
Some(h.name.as_str()),
h.lines.iter().map(|(src, l)| source_line(*src, l)).collect(),
),
None => (None, Vec::new()),
};
let total = body.len() as u16;
let view = area.height.saturating_sub(2);
let view_w = area.width.saturating_sub(2);
let max_top = total.saturating_sub(view);
scroll.vert = scroll.vert.min(max_top);
let top = max_top - scroll.vert;
let widest = body.iter().map(|l| l.width() as u16).max().unwrap_or(0);
let max_left = widest.saturating_sub(view_w);
scroll.horiz = scroll.horiz.min(max_left);
let title = match name {
Some(name) if scroll.vert > 0 => {
format!(" {name} · {total} lines · ↑{} ", scroll.vert)
}
Some(name) => format!(" {name} · {total} lines "),
None => " output ".to_string(),
};
let para = Paragraph::new(body)
.block(Block::default().borders(Borders::ALL).title(title))
.scroll((top, scroll.horiz));
f.render_widget(para, area);
}