use crate::paths::Paths;
use crate::session::{self, Session};
use anyhow::Result;
use std::io::{Read, Seek, SeekFrom};
use std::process::ExitCode;
use std::time::{Duration, Instant};
use ansi_to_tui::IntoText;
use babysit::cli::ShotFormat;
use babysit::render;
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseButton, MouseEventKind,
};
use ratatui::crossterm::execute;
use ratatui::prelude::*;
use ratatui::widgets::{
Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState,
};
const TICK: Duration = Duration::from_millis(250);
const MAX_REPLAY_BYTES: u64 = 16 * 1024 * 1024;
const PTY_ROWS: u16 = render::DEFAULT_SCREENSHOT_SIZE.0;
const PTY_COLS: u16 = render::DEFAULT_SCREENSHOT_SIZE.1;
const SCROLLBACK_ROWS: usize = 100_000;
const DEFAULT_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Clone, Copy)]
enum Filter {
Active,
Recent(Duration),
All,
}
pub fn cmd_watch(paths: &Paths, args: &[String]) -> Result<ExitCode> {
let mut initial: Option<String> = None;
let mut filter = Filter::Active;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--all" | "-a" => filter = Filter::All,
"--since" | "-s" => {
let v = iter.next().ok_or_else(|| {
anyhow::anyhow!("looop watch: --since needs a duration (e.g. 1d, 12h, 30m)")
})?;
filter = Filter::Recent(parse_duration(v)?);
}
other if other.starts_with("--since=") => {
filter = Filter::Recent(parse_duration(&other["--since=".len()..])?);
}
other if other.starts_with('-') => {
anyhow::bail!("looop watch: unknown flag '{other}' (--since <dur>, --all)");
}
id => {
if initial.is_none() {
initial = Some(id.to_string());
}
}
}
}
let mut terminal = ratatui::init();
let _ = execute!(std::io::stdout(), EnableMouseCapture);
let res = App::new(paths, initial, filter).run(&mut terminal, paths);
let _ = execute!(std::io::stdout(), DisableMouseCapture);
ratatui::restore();
res?;
Ok(ExitCode::SUCCESS)
}
fn parse_duration(s: &str) -> Result<Duration> {
let s = s.trim();
let (num, mult) = match s.chars().last() {
Some('s') => (&s[..s.len() - 1], 1),
Some('m') => (&s[..s.len() - 1], 60),
Some('h') => (&s[..s.len() - 1], 60 * 60),
Some('d') => (&s[..s.len() - 1], 24 * 60 * 60),
_ => (s, 1),
};
let n: u64 = num
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("looop watch: bad duration '{s}' (try 1d, 12h, 30m, 90s)"))?;
Ok(Duration::from_secs(n * mult))
}
struct App {
sessions: Vec<Session>,
list_state: ListState,
scroll_back: usize,
filter: Filter,
recent_window: Duration,
hidden: usize,
scrollbar: Option<ScrollbarHit>,
log: Option<LogReplay>,
selector: Option<SelectorHit>,
}
struct LogReplay {
id: String,
parser: vt100::Parser,
offset: u64,
prev_scrollback: usize,
seen: u64,
}
#[derive(Clone, Copy)]
struct SelectorHit {
area: Rect,
offset: usize,
}
#[derive(Clone, Copy)]
struct ScrollbarHit {
area: Rect,
max_scroll: usize,
}
impl App {
fn new(paths: &Paths, initial: Option<String>, filter: Filter) -> Self {
let recent_window = match filter {
Filter::Recent(w) => w,
_ => DEFAULT_WINDOW,
};
let (sessions, hidden) = list_filtered(paths, filter);
let mut list_state = ListState::default();
let idx = initial
.as_deref()
.and_then(|id| sessions.iter().position(|s| s.id == id))
.unwrap_or(0);
if !sessions.is_empty() {
list_state.select(Some(idx));
}
App {
sessions,
list_state,
scroll_back: 0,
filter,
recent_window,
hidden,
scrollbar: None,
log: None,
selector: None,
}
}
fn scrollbar_drag(&mut self, col: u16, row: u16) -> bool {
let Some(hit) = self.scrollbar else {
return false;
};
let a = hit.area;
if col + 1 < a.right() || col > a.right() || row < a.top() || row >= a.bottom() {
return false;
}
let span = a.height.saturating_sub(1);
let pos = if span == 0 {
0
} else {
let frac = (row - a.top()) as f64 / span as f64;
(frac * hit.max_scroll as f64).round() as usize
};
self.scroll_back = hit.max_scroll.saturating_sub(pos);
true
}
fn select_at(&mut self, col: u16, row: u16) -> bool {
let Some(hit) = self.selector else {
return false;
};
let a = hit.area;
if col < a.left() || col >= a.right() || row < a.top() || row >= a.bottom() {
return false;
}
let idx = hit.offset + (row - a.top()) as usize;
if idx >= self.sessions.len() {
return false; }
if Some(idx) != self.list_state.selected() {
self.list_state.select(Some(idx));
self.scroll_back = 0;
}
true
}
fn selected_id(&self) -> Option<&str> {
self.list_state
.selected()
.and_then(|i| self.sessions.get(i))
.map(|s| s.id.as_str())
}
fn refresh(&mut self, paths: &Paths) {
let keep = self.selected_id().map(str::to_string);
let (sessions, hidden) = list_filtered(paths, self.filter);
self.sessions = sessions;
self.hidden = hidden;
if self.sessions.is_empty() {
self.list_state.select(None);
return;
}
let idx = keep
.and_then(|id| self.sessions.iter().position(|s| s.id == id))
.unwrap_or(0);
self.list_state.select(Some(idx));
}
fn move_selection(&mut self, delta: isize) {
if self.sessions.is_empty() {
return;
}
let cur = self.list_state.selected().unwrap_or(0) as isize;
let next = (cur + delta).clamp(0, self.sessions.len() as isize - 1) as usize;
if Some(next) != self.list_state.selected() {
self.list_state.select(Some(next));
self.scroll_back = 0; }
}
fn sync_log(&mut self, paths: &Paths) {
let Some(id) = self.selected_id().map(str::to_string) else {
self.log = None;
return;
};
let path = paths.sessions().output_log_path(&id);
let Ok(meta) = std::fs::metadata(&path) else {
self.log = None; return;
};
let len = meta.len();
let reset = match &self.log {
Some(l) => l.id != id || len < l.offset,
None => true,
};
if reset {
let mut parser = vt100::Parser::new(PTY_ROWS, PTY_COLS, SCROLLBACK_ROWS);
let start = len.saturating_sub(MAX_REPLAY_BYTES);
if len > 0
&& let Ok(b) = read_from(&path, start)
{
parser.process(&b);
}
let prev_scrollback = scrollback_len(&mut parser);
self.log = Some(LogReplay {
id,
parser,
offset: len,
prev_scrollback,
seen: len - start,
});
return;
}
let Some(l) = self.log.as_mut() else { return };
if len <= l.offset {
return;
}
let delta = match read_from(&path, l.offset) {
Ok(b) => {
l.seen += b.len() as u64;
l.offset = len;
l.parser.process(&b);
let sb = scrollback_len(&mut l.parser);
let d = sb.saturating_sub(l.prev_scrollback);
l.prev_scrollback = sb;
d
}
Err(_) => 0,
};
if self.scroll_back > 0 && delta > 0 {
self.scroll_back = self.scroll_back.saturating_add(delta);
}
}
fn run(&mut self, terminal: &mut ratatui::DefaultTerminal, paths: &Paths) -> Result<()> {
let mut last_refresh = Instant::now()
.checked_sub(TICK)
.unwrap_or_else(Instant::now);
loop {
if last_refresh.elapsed() >= TICK {
self.refresh(paths);
last_refresh = Instant::now();
}
self.sync_log(paths);
terminal.draw(|f| self.draw(f))?;
if event::poll(TICK)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('c') if ctrl => break,
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Char('a') => {
self.filter = match self.filter {
Filter::Active => Filter::Recent(self.recent_window),
Filter::Recent(_) => Filter::All,
Filter::All => Filter::Active,
};
self.refresh(paths);
}
KeyCode::PageUp => {
self.scroll_back = self.scroll_back.saturating_add(10)
}
KeyCode::PageDown => {
self.scroll_back = self.scroll_back.saturating_sub(10)
}
KeyCode::Home => self.scroll_back = usize::MAX, KeyCode::End => self.scroll_back = 0, _ => {}
}
}
Event::Mouse(m) => match m.kind {
MouseEventKind::ScrollUp => {
self.scroll_back = self.scroll_back.saturating_add(3)
}
MouseEventKind::ScrollDown => {
self.scroll_back = self.scroll_back.saturating_sub(3)
}
MouseEventKind::Drag(MouseButton::Left) => {
self.scrollbar_drag(m.column, m.row);
}
MouseEventKind::Down(MouseButton::Left) => {
let _ = self.scrollbar_drag(m.column, m.row)
|| self.select_at(m.column, m.row);
}
_ => {}
},
_ => {}
}
}
}
Ok(())
}
fn draw(&mut self, frame: &mut Frame) {
let rows = self.sessions.len().clamp(1, 8) as u16;
let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(rows + 2)])
.split(frame.area());
self.draw_log(frame, chunks[0]);
self.draw_selector(frame, chunks[1]);
}
fn draw_log(&mut self, frame: &mut Frame, area: Rect) {
self.scrollbar = None;
let id = self.selected_id().unwrap_or("—").to_string();
let body = area;
let hint = match &self.log {
None => Some(format!("(no log for '{id}')")),
Some(l) if l.seen == 0 => Some("(no output yet)".to_string()),
Some(_) => None,
};
if let Some(hint) = hint {
self.scroll_back = 0;
let para = Paragraph::new(Span::styled(hint, Style::default().fg(Color::DarkGray)));
frame.render_widget(para, body);
return;
}
let pane_h = body.height.max(1) as usize;
let log = self.log.as_mut().expect("log present: None handled above");
let rows = log.parser.screen().size().0 as usize;
log.parser.screen_mut().set_scrollback(usize::MAX);
let max_back = log.parser.screen().scrollback();
let total = max_back + rows;
let max_scroll = total.saturating_sub(pane_h);
let back = self.scroll_back.min(max_scroll);
self.scroll_back = back;
let mut window: Vec<Option<Line>> = vec![None; pane_h];
let mut t = 0usize;
loop {
let off = (back + t * rows).min(max_back);
log.parser.screen_mut().set_scrollback(off);
let shot = render::render_screen(log.parser.screen(), ShotFormat::Ansi, false);
let screen = shot
.get("text")
.and_then(|v| v.as_str())
.unwrap_or_default();
let text = screen
.into_text()
.unwrap_or_else(|_| Text::from(screen.to_string()));
for (r, line) in text.lines.iter().enumerate().take(rows) {
if let Some(pos) = place_row(off, r, rows, back, pane_h)
&& window[pos].is_none()
{
window[pos] = Some(line.clone());
}
}
if off == max_back {
break; }
t += 1;
if t > pane_h / rows.max(1) + 2 {
break; }
}
let lines: Vec<Line> = if max_scroll == 0 {
let mut v: Vec<Line> = (0..total)
.rev()
.map(|k| window[k].take().unwrap_or_else(|| Line::from("")))
.collect();
v.resize(pane_h, Line::from(""));
v
} else {
(0..pane_h)
.rev()
.map(|k| window[k].take().unwrap_or_else(|| Line::from("")))
.collect()
};
frame.render_widget(Paragraph::new(Text::from(lines)), body);
if max_scroll > 0 {
let mut state = ScrollbarState::new(max_scroll).position(max_scroll - back);
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
frame.render_stateful_widget(bar, body, &mut state);
self.scrollbar = Some(ScrollbarHit {
area: body,
max_scroll,
});
}
}
fn draw_selector(&mut self, frame: &mut Frame, area: Rect) {
let items: Vec<ListItem> = if self.sessions.is_empty() {
vec![ListItem::new(Line::from(Span::styled(
" no sessions — run `looop up` to start the pulse",
Style::default().fg(Color::DarkGray),
)))]
} else {
self.sessions.iter().map(session_row).collect()
};
let scope = match self.filter {
Filter::All => String::from(" (all)"),
_ if self.hidden > 0 => format!(" ({} hidden)", self.hidden),
_ => String::new(),
};
let recency = match self.filter {
Filter::Active => "a recent",
Filter::Recent(_) => "a all",
Filter::All => "a active",
};
let title = format!(" sessions{scope} ↑/↓ select · {recency} · shift+drag copy · q quit ");
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.title_style(Style::default().add_modifier(Modifier::BOLD)),
)
.highlight_style(Style::default().bg(Color::White).fg(Color::Black))
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut self.list_state);
self.selector = if self.sessions.is_empty() {
None
} else {
Some(SelectorHit {
area: Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
},
offset: self.list_state.offset(),
})
};
}
}
fn list_filtered(paths: &Paths, filter: Filter) -> (Vec<Session>, usize) {
let all = session::list(paths);
let total = all.len();
let kept: Vec<Session> = match filter {
Filter::All => return (all, 0),
Filter::Active => all
.into_iter()
.filter(|s| s.alive || s.is_pulse())
.collect(),
Filter::Recent(window) => all
.into_iter()
.filter(|s| s.alive || s.is_pulse() || s.idle_for().map(|d| d < window).unwrap_or(true))
.collect(),
};
let hidden = total - kept.len();
(kept, hidden)
}
fn session_row(s: &Session) -> ListItem<'static> {
let (dot, color) = match (s.alive, s.state.as_str()) {
(true, _) => ("●", Color::Green),
(false, "exited") => ("✓", Color::DarkGray),
(false, "killed") => ("✗", Color::Red),
(false, _) => ("○", Color::DarkGray),
};
let label = if s.is_pulse() {
format!("{} (pulse)", s.id)
} else {
s.id.clone()
};
let detail = match s.exit_code {
Some(code) if !s.alive => format!("{} (exit {code})", s.state),
_ => s.state.clone(),
};
ListItem::new(Line::from(vec![
Span::styled(format!("{dot} "), Style::default().fg(color)),
Span::raw(format!("{label:<20} ")),
Span::styled(detail, Style::default().fg(Color::DarkGray)),
]))
}
fn place_row(off: usize, r: usize, rows: usize, back: usize, pane_h: usize) -> Option<usize> {
let ft = off + (rows - 1 - r);
if ft >= back && ft < back + pane_h {
Some(ft - back)
} else {
None
}
}
fn scrollback_len(parser: &mut vt100::Parser) -> usize {
parser.screen_mut().set_scrollback(usize::MAX);
parser.screen().scrollback()
}
fn read_from(path: &std::path::Path, start: u64) -> std::io::Result<Vec<u8>> {
let mut f = std::fs::File::open(path)?;
if start > 0 {
f.seek(SeekFrom::Start(start))?;
}
let mut buf = Vec::new();
f.read_to_end(&mut buf)?;
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn tmp(name: &str, contents: &[u8]) -> std::path::PathBuf {
let p =
std::env::temp_dir().join(format!("looop-watch-test-{}-{name}", std::process::id()));
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(contents).unwrap();
p
}
#[test]
fn parse_duration_units() {
assert_eq!(parse_duration("90").unwrap(), Duration::from_secs(90));
assert_eq!(parse_duration("45s").unwrap(), Duration::from_secs(45));
assert_eq!(parse_duration("30m").unwrap(), Duration::from_secs(1800));
assert_eq!(parse_duration("12h").unwrap(), Duration::from_secs(43200));
assert_eq!(parse_duration("1d").unwrap(), Duration::from_secs(86400));
assert_eq!(parse_duration(" 2d ").unwrap(), Duration::from_secs(172800));
}
#[test]
fn parse_duration_rejects_garbage() {
assert!(parse_duration("").is_err());
assert!(parse_duration("abc").is_err());
assert!(parse_duration("1w").is_err());
assert!(parse_duration("d").is_err());
}
#[test]
fn read_from_start_returns_whole_file() {
let p = tmp("whole", b"hello world");
assert_eq!(read_from(&p, 0).unwrap(), b"hello world");
let _ = std::fs::remove_file(&p);
}
#[test]
fn read_from_offset_returns_appended_tail() {
let p = tmp("appended", b"0123456789");
assert_eq!(read_from(&p, 6).unwrap(), b"6789");
let _ = std::fs::remove_file(&p);
}
#[test]
fn read_from_missing_file_is_err() {
let p = std::env::temp_dir().join("looop-watch-test-does-not-exist");
assert!(read_from(&p, 0).is_err());
}
#[test]
fn place_row_follows_tail() {
assert_eq!(place_row(0, 23, 24, 0, 24), Some(0));
assert_eq!(place_row(0, 0, 24, 0, 24), Some(23));
assert_eq!(place_row(24, 23, 24, 0, 24), None);
}
#[test]
fn place_row_scrolled_back() {
assert_eq!(place_row(0, 23, 24, 10, 24), None); assert_eq!(place_row(0, 0, 24, 10, 24), Some(13)); assert_eq!(place_row(24, 23, 24, 10, 24), Some(14)); assert_eq!(place_row(24, 14, 24, 10, 24), Some(23)); assert_eq!(place_row(24, 13, 24, 10, 24), None); }
#[test]
fn place_row_taller_pane_tiles() {
assert_eq!(place_row(0, 23, 24, 0, 50), Some(0));
assert_eq!(place_row(0, 0, 24, 0, 50), Some(23));
assert_eq!(place_row(24, 23, 24, 0, 50), Some(24));
assert_eq!(place_row(24, 0, 24, 0, 50), Some(47));
}
}