From cf734091ab489c4d38b9ac141a8eb5a48ee403bd Mon Sep 17 00:00:00 2001 From: proboscis Date: Tue, 20 Jan 2026 16:03:07 +0900 Subject: [PATCH] feat: add ratatui TUI with interactive process monitor Implements Phase 1-3 of ISSUE-035: - Add ratatui/crossterm/tokio dependencies for TUI infrastructure - Create 'runbox monitor' command for interactive process monitoring - Real-time auto-refresh with configurable tick rate (default: 2s) - Keyboard navigation (j/k or arrows) through process list - Enter to view logs, 's' to stop, 'a' to attach (tmux/zellij) - Interactive log viewer with scrollback, search, and follow mode - Color-coded status display (green=running, blue=done, red=failed) - Graceful terminal cleanup on exit --- crates/runbox-cli/Cargo.toml | 7 + crates/runbox-cli/src/main.rs | 69 ++++ crates/runbox-cli/src/tui/app.rs | 314 ++++++++++++++++ crates/runbox-cli/src/tui/event.rs | 240 ++++++++++++ crates/runbox-cli/src/tui/mod.rs | 13 + crates/runbox-cli/src/tui/ui.rs | 207 +++++++++++ crates/runbox-cli/src/tui/views/logs.rs | 403 +++++++++++++++++++++ crates/runbox-cli/src/tui/views/mod.rs | 9 + crates/runbox-cli/src/tui/views/monitor.rs | 314 ++++++++++++++++ 9 files changed, 1576 insertions(+) create mode 100644 crates/runbox-cli/src/tui/app.rs create mode 100644 crates/runbox-cli/src/tui/event.rs create mode 100644 crates/runbox-cli/src/tui/mod.rs create mode 100644 crates/runbox-cli/src/tui/ui.rs create mode 100644 crates/runbox-cli/src/tui/views/logs.rs create mode 100644 crates/runbox-cli/src/tui/views/mod.rs create mode 100644 crates/runbox-cli/src/tui/views/monitor.rs diff --git a/crates/runbox-cli/Cargo.toml b/crates/runbox-cli/Cargo.toml index cfb90d8..a55eccf 100644 --- a/crates/runbox-cli/Cargo.toml +++ b/crates/runbox-cli/Cargo.toml @@ -21,6 +21,13 @@ dialoguer = "0.11" chrono = { version = "0.4", features = ["serde"] } log = "0.4" +# TUI dependencies +ratatui = "0.28" +crossterm = "0.28" +tokio = { version = "1", features = ["full", "sync", "time"] } +futures = "0.3" +libc = "0.2" + [features] # Enable tests that require tmux to be installed tmux-tests = [] diff --git a/crates/runbox-cli/src/main.rs b/crates/runbox-cli/src/main.rs index 39cfe8c..3c14951 100644 --- a/crates/runbox-cli/src/main.rs +++ b/crates/runbox-cli/src/main.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Context, Result}; +mod tui; use chrono::Utc; use clap::{Parser, Subcommand, ValueEnum}; use dialoguer::{theme::ColorfulTheme, Input}; @@ -696,6 +697,40 @@ CONTENTS: - Examples")] /// Display the full tutorial in the terminal Tutorial, + /// Interactive process monitor with real-time updates + #[command(after_help = "\ +EXAMPLES: + # Launch the interactive monitor + runbox monitor + + # Specify tick rate for refresh (default: 2 seconds) + runbox monitor --tick-rate 1 + +FEATURES: + - Real-time process list with auto-refresh + - Keyboard navigation (j/k or arrows) + - Press Enter to view logs for selected process + - Press 's' to stop a running process + - Press 'a' to attach to tmux/zellij session + - Press 'q' to quit + +KEYBINDINGS: + ↑/k Move selection up + ↓/j Move selection down + Enter View logs for selected process + s Stop selected process (if running) + a Attach to tmux/zellij session + r Refresh process list + q Quit + +RELATED COMMANDS: + runbox ps Static process list (non-interactive) + runbox logs View logs for a specific run")] + Monitor { + /// Tick rate in seconds for auto-refresh (default: 2) + #[arg(long, default_value = "2")] + tick_rate: u64, + }, } #[derive(Subcommand)] enum DaemonCommands { @@ -1084,12 +1119,46 @@ fn main() -> Result<()> { DaemonCommands::Ping => cmd_daemon_ping(), }, Commands::Tutorial => cmd_tutorial(), + Commands::Monitor { tick_rate } => cmd_monitor(&storage, tick_rate), } } fn cmd_tutorial() -> Result<()> { println!("{}", TUTORIAL); Ok(()) } + +// === Monitor Command === +fn cmd_monitor(_storage: &Storage, tick_rate: u64) -> Result<()> { + use std::time::Duration; + use crate::tui::{run_app, app::execute_post_exit_action}; + + let tick = Duration::from_secs(tick_rate); + + // Create a new storage instance for the TUI + let tui_storage = if let Ok(home) = std::env::var("RUNBOX_HOME") { + Storage::with_base_dir(PathBuf::from(home))? + } else { + Storage::new()? + }; + + // Run the TUI + match run_app(tui_storage, tick) { + Ok(Some(action)) => { + // Execute post-exit action (e.g., attach to tmux) + execute_post_exit_action(action)?; + } + Ok(None) => { + // Normal exit + } + Err(e) => { + // TUI failed, show error + eprintln!("TUI error: {}", e); + return Err(e); + } + } + + Ok(()) +} // === Daemon Commands === fn cmd_daemon_start() -> Result<()> { use std::process::Command as StdCommand; diff --git a/crates/runbox-cli/src/tui/app.rs b/crates/runbox-cli/src/tui/app.rs new file mode 100644 index 0000000..5d62e64 --- /dev/null +++ b/crates/runbox-cli/src/tui/app.rs @@ -0,0 +1,314 @@ +#![allow(dead_code)] +//! +//! Manages the application state, event loop, and view switching. + +use anyhow::{Context, Result}; +use crossterm::{ + event::DisableMouseCapture, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use runbox_core::Storage; +use std::io::{self, Stdout}; + +use std::process::Command; +use std::time::Duration; + +use super::event::{Event, EventHandler}; +use super::views::{LogView, MonitorAction, MonitorView}; + +/// Current view/mode of the application +#[derive(Debug, Clone, PartialEq)] +pub enum AppMode { + /// Process monitor (main view) + Monitor, + /// Log viewer for a specific run + Logs { run_id: String }, +} + +/// Main TUI application state +pub struct App { + /// Storage for data access + storage: Storage, + /// Current mode/view + mode: AppMode, + /// Process monitor view + monitor: MonitorView, + /// Log viewer (lazily created when needed) + log_view: Option, + /// Should quit the application + should_quit: bool, + /// Message to display after exiting TUI (for attach) + exit_message: Option, + /// Action to perform after exiting (e.g., attach to tmux) + post_exit_action: Option, +} + +/// Actions to perform after exiting the TUI +#[derive(Debug, Clone)] +pub enum PostExitAction { + AttachTmux { session: String }, + AttachZellij { session: String }, +} + +impl App { + /// Create a new application with the given storage + pub fn new(storage: Storage) -> Self { + Self { + storage, + mode: AppMode::Monitor, + monitor: MonitorView::new(), + log_view: None, + should_quit: false, + exit_message: None, + post_exit_action: None, + } + } + + /// Initialize the application (load initial data) + pub fn init(&mut self) -> Result<()> { + self.monitor.refresh(&self.storage)?; + Ok(()) + } + + /// Handle a tick event (periodic refresh) + pub fn on_tick(&mut self) -> Result<()> { + match self.mode { + AppMode::Monitor => { + self.monitor.refresh(&self.storage)?; + } + AppMode::Logs { .. } => { + if let Some(ref mut log_view) = self.log_view { + log_view.refresh()?; + } + } + } + Ok(()) + } + + /// Handle a key event + pub fn on_key(&mut self, key: crossterm::event::KeyEvent) -> Result<()> { + match self.mode { + AppMode::Monitor => { + let (quit, action) = self.monitor.handle_key(key); + self.should_quit = quit; + + if let Some(action) = action { + self.handle_monitor_action(action)?; + } + } + AppMode::Logs { .. } => { + if let Some(ref mut log_view) = self.log_view { + let (go_back, _action) = log_view.handle_key(key); + if go_back { + self.mode = AppMode::Monitor; + self.log_view = None; + } + } + } + } + Ok(()) + } + + /// Handle an action from the monitor view + fn handle_monitor_action(&mut self, action: MonitorAction) -> Result<()> { + match action { + MonitorAction::ViewLogs(run_id) => { + self.switch_to_logs(&run_id)?; + } + MonitorAction::StopProcess(run_id) => { + self.stop_process(&run_id)?; + } + MonitorAction::AttachProcess(run_id) => { + self.attach_process(&run_id)?; + } + MonitorAction::Refresh => { + self.monitor.refresh(&self.storage)?; + } + } + Ok(()) + } + + /// Switch to log view for a specific run + fn switch_to_logs(&mut self, run_id: &str) -> Result<()> { + let run = self.storage.load_run(run_id)?; + let log_path = run.log_ref.clone() + .map(|lr| lr.path) + .unwrap_or_else(|| self.storage.log_path(run_id)); + + let command = run.exec.argv.join(" "); + let short_id = run.short_id().to_string(); + + let mut log_view = LogView::new( + run_id.to_string(), + short_id, + command, + log_path, + ); + log_view.refresh()?; + + self.log_view = Some(log_view); + self.mode = AppMode::Logs { run_id: run_id.to_string() }; + Ok(()) + } + + /// Stop a running process + fn stop_process(&mut self, run_id: &str) -> Result<()> { + use runbox_core::DaemonClient; + + let client = DaemonClient::new(); + if client.is_running() { + client.stop(run_id, false)?; + } else { + // Fallback: try to kill directly if we have PID + let run = self.storage.load_run(run_id)?; + if let Some(runbox_core::RuntimeHandle::Background { pid, .. }) = run.handle { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + } + + // Refresh to show updated status + self.monitor.refresh(&self.storage)?; + Ok(()) + } + + /// Attach to a running tmux/zellij session + fn attach_process(&mut self, run_id: &str) -> Result<()> { + let run = self.storage.load_run(run_id)?; + + match run.handle { + Some(runbox_core::RuntimeHandle::Tmux { session, .. }) => { + self.post_exit_action = Some(PostExitAction::AttachTmux { session }); + self.should_quit = true; + } + Some(runbox_core::RuntimeHandle::Zellij { session, .. }) => { + self.post_exit_action = Some(PostExitAction::AttachZellij { session }); + self.should_quit = true; + } + _ => { + // Not a tmux/zellij run, nothing to attach to + } + } + + Ok(()) + } + + /// Render the current view + pub fn render(&mut self, frame: &mut ratatui::Frame) { + let area = frame.area(); + + match self.mode { + AppMode::Monitor => { + self.monitor.render(frame, area); + } + AppMode::Logs { .. } => { + if let Some(ref mut log_view) = self.log_view { + log_view.render(frame, area); + } + } + } + } + + /// Check if the application should quit + pub fn should_quit(&self) -> bool { + self.should_quit + } + + /// Get the post-exit action + pub fn post_exit_action(&self) -> Option<&PostExitAction> { + self.post_exit_action.as_ref() + } +} + +/// Terminal wrapper for cleanup +struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + fn new() -> Result { + enable_raw_mode().context("Failed to enable raw mode")?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen).context("Failed to enter alternate screen")?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend).context("Failed to create terminal")?; + Ok(Self { terminal }) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture); + let _ = self.terminal.show_cursor(); + } +} + +/// Run the TUI application +pub fn run_app(storage: Storage, tick_rate: Duration) -> Result> { + // Set up terminal + let mut guard = TerminalGuard::new()?; + + // Create app + let mut app = App::new(storage); + app.init()?; + + // Create event handler + let events = EventHandler::new(tick_rate); + + // Main loop + loop { + // Draw + guard.terminal.draw(|f| app.render(f))?; + + // Handle events + match events.next()? { + Event::Key(key) => { + app.on_key(key)?; + } + Event::Tick => { + app.on_tick()?; + } + Event::Resize(_, _) => { + // Terminal will handle resize automatically + } + } + + if app.should_quit() { + break; + } + } + + // Return post-exit action + Ok(app.post_exit_action().cloned()) +} + +/// Execute post-exit action (after terminal is restored) +pub fn execute_post_exit_action(action: PostExitAction) -> Result<()> { + match action { + PostExitAction::AttachTmux { session } => { + let status = Command::new("tmux") + .args(["attach-session", "-t", &session]) + .status() + .context("Failed to attach to tmux session")?; + + if !status.success() { + anyhow::bail!("tmux attach failed with status: {:?}", status.code()); + } + } + PostExitAction::AttachZellij { session } => { + let status = Command::new("zellij") + .args(["attach", &session]) + .status() + .context("Failed to attach to zellij session")?; + + if !status.success() { + anyhow::bail!("zellij attach failed with status: {:?}", status.code()); + } + } + } + Ok(()) +} diff --git a/crates/runbox-cli/src/tui/event.rs b/crates/runbox-cli/src/tui/event.rs new file mode 100644 index 0000000..7ffb808 --- /dev/null +++ b/crates/runbox-cli/src/tui/event.rs @@ -0,0 +1,240 @@ +#![allow(dead_code)] +//! +//! Manages keyboard events and tick events for auto-refresh. + +use anyhow::Result; +use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +/// Terminal events +#[derive(Debug, Clone)] +pub enum Event { + /// Keyboard input + Key(KeyEvent), + /// Terminal tick for refresh + Tick, + /// Terminal resize + Resize(u16, u16), +} + +/// Event handler that spawns a thread to listen for events +pub struct EventHandler { + receiver: mpsc::Receiver, + _sender: mpsc::Sender, +} + +impl EventHandler { + /// Create a new event handler with the specified tick rate + pub fn new(tick_rate: Duration) -> Self { + let (sender, receiver) = mpsc::channel(); + let _sender = sender.clone(); + + thread::spawn(move || { + let mut last_tick = Instant::now(); + loop { + // Calculate remaining time until next tick + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or(Duration::ZERO); + + // Poll for events with timeout + if event::poll(timeout).unwrap_or(false) { + match event::read() { + Ok(CrosstermEvent::Key(key)) => { + // Ignore key release events on some platforms + if key.kind == crossterm::event::KeyEventKind::Press { + if sender.send(Event::Key(key)).is_err() { + return; + } + } + } + Ok(CrosstermEvent::Resize(w, h)) => { + if sender.send(Event::Resize(w, h)).is_err() { + return; + } + } + _ => {} + } + } + + // Send tick event at regular intervals + if last_tick.elapsed() >= tick_rate { + if sender.send(Event::Tick).is_err() { + return; + } + last_tick = Instant::now(); + } + } + }); + + Self { receiver, _sender } + } + + /// Wait for the next event + pub fn next(&self) -> Result { + Ok(self.receiver.recv()?) + } +} + +/// Key bindings helper +pub struct KeyBindings; + +impl KeyBindings { + /// Check if key is quit (q or Ctrl+C) + pub fn is_quit(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + .. + } | KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + .. + } + ) + } + + /// Check if key is up (k or Up arrow) + pub fn is_up(key: KeyEvent) -> bool { + matches!( + key.code, + KeyCode::Up | KeyCode::Char('k') + ) + } + + /// Check if key is down (j or Down arrow) + pub fn is_down(key: KeyEvent) -> bool { + matches!( + key.code, + KeyCode::Down | KeyCode::Char('j') + ) + } + + /// Check if key is select/enter + pub fn is_select(key: KeyEvent) -> bool { + matches!(key.code, KeyCode::Enter) + } + + /// Check if key is back/escape + pub fn is_back(key: KeyEvent) -> bool { + matches!(key.code, KeyCode::Esc | KeyCode::Backspace) + } + + /// Check if key is stop (s) + pub fn is_stop(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::NONE, + .. + } + ) + } + + /// Check if key is attach (a) + pub fn is_attach(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::NONE, + .. + } + ) + } + + /// Check if key is refresh (r) + pub fn is_refresh(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::NONE, + .. + } + ) + } + + /// Check if key is search (/) + pub fn is_search(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('/'), + modifiers: KeyModifiers::NONE, + .. + } + ) + } + + /// Check if key is follow mode toggle (f) + pub fn is_follow(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::NONE, + .. + } + ) + } + + /// Check if key is go to top (g) + pub fn is_goto_top(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('g'), + modifiers: KeyModifiers::NONE, + .. + } + ) + } + + /// Check if key is go to bottom (G) + pub fn is_goto_bottom(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('G'), + modifiers: KeyModifiers::SHIFT, + .. + } | KeyEvent { + code: KeyCode::Char('G'), + modifiers: KeyModifiers::NONE, + .. + } + ) + } + + /// Check if key is page up + pub fn is_page_up(key: KeyEvent) -> bool { + matches!(key.code, KeyCode::PageUp) + } + + /// Check if key is page down + pub fn is_page_down(key: KeyEvent) -> bool { + matches!(key.code, KeyCode::PageDown) + } + + /// Check if key is help (?) + pub fn is_help(key: KeyEvent) -> bool { + matches!( + key, + KeyEvent { + code: KeyCode::Char('?'), + .. + } + ) + } + + /// Check if key is tab (for pane switching) + pub fn is_tab(key: KeyEvent) -> bool { + matches!(key.code, KeyCode::Tab) + } +} diff --git a/crates/runbox-cli/src/tui/mod.rs b/crates/runbox-cli/src/tui/mod.rs new file mode 100644 index 0000000..10f6282 --- /dev/null +++ b/crates/runbox-cli/src/tui/mod.rs @@ -0,0 +1,13 @@ +//! Terminal User Interface (TUI) for runbox +//! +//! Provides an interactive terminal interface with: +//! - Process monitor with real-time updates +//! - Interactive log viewer with scrollback and search +//! - Keyboard navigation and actions + +pub mod app; +pub mod event; +pub mod ui; +pub mod views; + +pub use app::run_app; diff --git a/crates/runbox-cli/src/tui/ui.rs b/crates/runbox-cli/src/tui/ui.rs new file mode 100644 index 0000000..064ef73 --- /dev/null +++ b/crates/runbox-cli/src/tui/ui.rs @@ -0,0 +1,207 @@ +#![allow(dead_code)] + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use runbox_core::RunStatus; + +/// Color scheme for the TUI +pub struct Colors; + +impl Colors { + /// Status color based on run status + pub fn status(status: &RunStatus) -> Color { + match status { + RunStatus::Running => Color::Green, + RunStatus::Pending => Color::Yellow, + RunStatus::Exited => Color::Blue, + RunStatus::Failed => Color::Red, + RunStatus::Killed => Color::Magenta, + RunStatus::Unknown => Color::DarkGray, + } + } + + /// Highlighted/selected row + pub fn selected() -> Color { + Color::Cyan + } + + /// Header text + pub fn header() -> Color { + Color::White + } + + /// Muted/secondary text + pub fn muted() -> Color { + Color::DarkGray + } + + /// Border color + pub fn border() -> Color { + Color::Gray + } + + /// Active border + pub fn active_border() -> Color { + Color::Cyan + } + + /// Help text + pub fn help() -> Color { + Color::DarkGray + } +} + +/// Style presets +pub struct Styles; + +impl Styles { + /// Selected row style + pub fn selected() -> Style { + Style::default() + .fg(Colors::selected()) + .add_modifier(Modifier::BOLD) + } + + /// Header style + pub fn header() -> Style { + Style::default() + .fg(Colors::header()) + .add_modifier(Modifier::BOLD) + } + + /// Muted text style + pub fn muted() -> Style { + Style::default().fg(Colors::muted()) + } + + /// Normal text + pub fn normal() -> Style { + Style::default() + } + + /// Status style + pub fn status(status: &RunStatus) -> Style { + Style::default().fg(Colors::status(status)) + } +} + +/// Create a centered rect of given percentage width and height +pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +/// Render help bar at the bottom +pub fn render_help_bar(frame: &mut Frame, area: Rect, items: &[(&str, &str)]) { + let spans: Vec = items + .iter() + .flat_map(|(key, desc)| { + vec![ + Span::styled(format!("[{}]", key), Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled(*desc, Style::default().fg(Colors::help())), + Span::raw(" "), + ] + }) + .collect(); + + let help_line = Line::from(spans); + let help = Paragraph::new(help_line); + frame.render_widget(help, area); +} + +/// Render a status badge +pub fn status_span(status: &RunStatus) -> Span<'static> { + let text = format!("{:8}", status.to_string()); + Span::styled(text, Styles::status(status)) +} + +/// Format duration for display +pub fn format_duration(seconds: i64) -> String { + if seconds < 0 { + return "N/A".to_string(); + } + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + + if hours > 0 { + format!("{:02}:{:02}:{:02}", hours, minutes, secs) + } else { + format!("{:02}:{:02}", minutes, secs) + } +} + +/// Format timestamp for display +pub fn format_time(dt: &chrono::DateTime) -> String { + dt.format("%H:%M:%S").to_string() +} + +/// Truncate string to fit width with ellipsis +pub fn truncate_str(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else if max_len <= 3 { + s.chars().take(max_len).collect() + } else { + format!("{}...", &s[..max_len - 3]) + } +} + +/// Create a block with title and borders +pub fn titled_block(title: &str, active: bool) -> Block<'_> { + let border_style = if active { + Style::default().fg(Colors::active_border()) + } else { + Style::default().fg(Colors::border()) + }; + + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(0), "00:00"); + assert_eq!(format_duration(59), "00:59"); + assert_eq!(format_duration(60), "01:00"); + assert_eq!(format_duration(3599), "59:59"); + assert_eq!(format_duration(3600), "01:00:00"); + assert_eq!(format_duration(3661), "01:01:01"); + assert_eq!(format_duration(-1), "N/A"); + } + + #[test] + fn test_truncate_str() { + assert_eq!(truncate_str("hello", 10), "hello"); + assert_eq!(truncate_str("hello world", 8), "hello..."); + assert_eq!(truncate_str("hi", 2), "hi"); + assert_eq!(truncate_str("hello", 3), "hel"); + } +} diff --git a/crates/runbox-cli/src/tui/views/logs.rs b/crates/runbox-cli/src/tui/views/logs.rs new file mode 100644 index 0000000..5a54300 --- /dev/null +++ b/crates/runbox-cli/src/tui/views/logs.rs @@ -0,0 +1,403 @@ +#![allow(dead_code)] +//! +//! Interactive log viewer with scrollback, search, and follow mode. + +use anyhow::Result; +use crossterm::event::KeyEvent; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, + Frame, +}; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; + +use crate::tui::event::KeyBindings; +use crate::tui::ui::{render_help_bar, titled_block}; + +/// Log viewer state +pub struct LogView { + /// Run ID being viewed + run_id: String, + /// Run short ID for display + short_id: String, + /// Command being run + command: String, + /// Path to log file + log_path: PathBuf, + /// Loaded log lines + lines: Vec, + /// Current scroll position (line offset) + scroll_offset: usize, + /// Viewport height (number of visible lines) + viewport_height: usize, + /// Follow mode (auto-scroll to bottom) + follow_mode: bool, + /// Search query + search_query: String, + /// Is in search input mode + search_mode: bool, + /// Current search match index + search_match_idx: Option, + /// All matching line indices + search_matches: Vec, + /// File size at last read (for detecting new content) + last_file_size: u64, +} + +impl LogView { + pub fn new(run_id: String, short_id: String, command: String, log_path: PathBuf) -> Self { + Self { + run_id, + short_id, + command, + log_path, + lines: Vec::new(), + scroll_offset: 0, + viewport_height: 20, + follow_mode: true, // Start in follow mode + search_query: String::new(), + search_mode: false, + search_match_idx: None, + search_matches: Vec::new(), + last_file_size: 0, + } + } + + /// Load or refresh log content + pub fn refresh(&mut self) -> Result<()> { + if !self.log_path.exists() { + self.lines = vec!["Log file not found (run may still be starting...)".to_string()]; + return Ok(()); + } + + let file = File::open(&self.log_path)?; + let metadata = file.metadata()?; + let new_size = metadata.len(); + + // Only reload if file has changed + if new_size != self.last_file_size { + let reader = BufReader::new(file); + self.lines = reader.lines().filter_map(|l| l.ok()).collect(); + self.last_file_size = new_size; + + // Update search matches if we have a query + if !self.search_query.is_empty() { + self.update_search_matches(); + } + + // Auto-scroll to bottom in follow mode + if self.follow_mode { + self.scroll_to_bottom(); + } + } + + Ok(()) + } + + /// Scroll to the bottom of the log + pub fn scroll_to_bottom(&mut self) { + if self.lines.len() > self.viewport_height { + self.scroll_offset = self.lines.len() - self.viewport_height; + } else { + self.scroll_offset = 0; + } + } + + /// Scroll to the top of the log + pub fn scroll_to_top(&mut self) { + self.scroll_offset = 0; + self.follow_mode = false; + } + + /// Handle keyboard input + /// Returns: (should_go_back, action) + pub fn handle_key(&mut self, key: KeyEvent) -> (bool, Option) { + // Handle search mode input + if self.search_mode { + return self.handle_search_input(key); + } + + if KeyBindings::is_back(key) || KeyBindings::is_quit(key) { + return (true, None); + } + + if KeyBindings::is_up(key) { + self.scroll_up(1); + self.follow_mode = false; + return (false, None); + } + + if KeyBindings::is_down(key) { + self.scroll_down(1); + return (false, None); + } + + if KeyBindings::is_page_up(key) { + self.scroll_up(self.viewport_height.saturating_sub(1)); + self.follow_mode = false; + return (false, None); + } + + if KeyBindings::is_page_down(key) { + self.scroll_down(self.viewport_height.saturating_sub(1)); + return (false, None); + } + + if KeyBindings::is_goto_top(key) { + self.scroll_to_top(); + return (false, None); + } + + if KeyBindings::is_goto_bottom(key) { + self.scroll_to_bottom(); + self.follow_mode = true; + return (false, None); + } + + if KeyBindings::is_follow(key) { + self.follow_mode = !self.follow_mode; + if self.follow_mode { + self.scroll_to_bottom(); + } + return (false, None); + } + + if KeyBindings::is_search(key) { + self.search_mode = true; + self.search_query.clear(); + return (false, None); + } + + // n = next match, N = previous match + if let crossterm::event::KeyCode::Char('n') = key.code { + if key.modifiers == crossterm::event::KeyModifiers::NONE { + self.next_match(); + } else if key.modifiers == crossterm::event::KeyModifiers::SHIFT { + self.prev_match(); + } + } + + (false, None) + } + + fn handle_search_input(&mut self, key: KeyEvent) -> (bool, Option) { + match key.code { + crossterm::event::KeyCode::Enter => { + self.search_mode = false; + self.update_search_matches(); + if !self.search_matches.is_empty() { + self.search_match_idx = Some(0); + self.scroll_to_match(0); + } + } + crossterm::event::KeyCode::Esc => { + self.search_mode = false; + self.search_query.clear(); + self.search_matches.clear(); + self.search_match_idx = None; + } + crossterm::event::KeyCode::Backspace => { + self.search_query.pop(); + } + crossterm::event::KeyCode::Char(c) => { + self.search_query.push(c); + } + _ => {} + } + (false, None) + } + + fn update_search_matches(&mut self) { + self.search_matches.clear(); + if self.search_query.is_empty() { + return; + } + let query_lower = self.search_query.to_lowercase(); + for (idx, line) in self.lines.iter().enumerate() { + if line.to_lowercase().contains(&query_lower) { + self.search_matches.push(idx); + } + } + } + + fn next_match(&mut self) { + if self.search_matches.is_empty() { + return; + } + let next_idx = match self.search_match_idx { + Some(idx) => (idx + 1) % self.search_matches.len(), + None => 0, + }; + self.search_match_idx = Some(next_idx); + self.scroll_to_match(next_idx); + } + + fn prev_match(&mut self) { + if self.search_matches.is_empty() { + return; + } + let prev_idx = match self.search_match_idx { + Some(idx) => { + if idx > 0 { + idx - 1 + } else { + self.search_matches.len() - 1 + } + } + None => 0, + }; + self.search_match_idx = Some(prev_idx); + self.scroll_to_match(prev_idx); + } + + fn scroll_to_match(&mut self, match_idx: usize) { + if let Some(&line_idx) = self.search_matches.get(match_idx) { + // Center the match in the viewport + if line_idx >= self.viewport_height / 2 { + self.scroll_offset = line_idx - self.viewport_height / 2; + } else { + self.scroll_offset = 0; + } + self.follow_mode = false; + } + } + + fn scroll_up(&mut self, lines: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(lines); + } + + fn scroll_down(&mut self, lines: usize) { + let max_offset = self.lines.len().saturating_sub(self.viewport_height); + self.scroll_offset = (self.scroll_offset + lines).min(max_offset); + + // Enable follow mode if at bottom + if self.scroll_offset >= max_offset { + self.follow_mode = true; + } + } + + /// Render the log view + pub fn render(&mut self, frame: &mut Frame, area: Rect) { + // Update viewport height + let inner_height = area.height.saturating_sub(4); // Borders + help bar + self.viewport_height = inner_height as usize; + + // Layout: main content, help bar + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(1), + ]) + .split(area); + + self.render_content(frame, chunks[0]); + self.render_help(frame, chunks[1]); + } + + fn render_content(&self, frame: &mut Frame, area: Rect) { + let title = format!( + " Logs: {} ({}) {} ", + self.short_id, + crate::tui::ui::truncate_str(&self.command, 30), + if self.follow_mode { "[FOLLOW]" } else { "" } + ); + + // Build styled lines + let visible_lines: Vec = self.lines + .iter() + .enumerate() + .skip(self.scroll_offset) + .take(self.viewport_height) + .map(|(idx, line)| { + // Highlight search matches + let is_match = self.search_matches.contains(&idx); + let is_current_match = self.search_match_idx + .map(|mi| self.search_matches.get(mi) == Some(&idx)) + .unwrap_or(false); + + let style = if is_current_match { + Style::default().bg(Color::Yellow).fg(Color::Black) + } else if is_match { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + Line::from(Span::styled(line.clone(), style)) + }) + .collect(); + + let mut block = titled_block(&title, true); + + // Show search input if in search mode + if self.search_mode { + let search_title = format!(" Search: {} ", self.search_query); + block = block.title_bottom(Line::from(search_title)); + } else if !self.search_query.is_empty() { + let match_info = if self.search_matches.is_empty() { + "No matches".to_string() + } else { + let current = self.search_match_idx.map(|i| i + 1).unwrap_or(0); + format!("{}/{} matches", current, self.search_matches.len()) + }; + let search_info = format!(" /{} ({}) ", self.search_query, match_info); + block = block.title_bottom(Line::from(search_info)); + } + + let paragraph = Paragraph::new(visible_lines) + .block(block); + + frame.render_widget(paragraph, area); + + // Render scrollbar + if self.lines.len() > self.viewport_height { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + + let mut scrollbar_state = ScrollbarState::new(self.lines.len()) + .position(self.scroll_offset); + + let scrollbar_area = Rect { + x: area.x + area.width - 1, + y: area.y + 1, + width: 1, + height: area.height.saturating_sub(2), + }; + + frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state); + } + } + + fn render_help(&self, frame: &mut Frame, area: Rect) { + let help_items = if self.search_mode { + vec![ + ("Enter", "Search"), + ("Esc", "Cancel"), + ] + } else { + vec![ + ("↑/k", "Up"), + ("↓/j", "Down"), + ("/", "Search"), + ("n/N", "Next/Prev match"), + ("g/G", "Top/Bottom"), + ("f", "Follow"), + ("q/Esc", "Back"), + ] + }; + + render_help_bar(frame, area, &help_items); + } +} + +/// Actions that can be triggered from the log view +#[derive(Debug, Clone)] +pub enum LogAction { + // Future: copy to clipboard, save to file, etc. +} diff --git a/crates/runbox-cli/src/tui/views/mod.rs b/crates/runbox-cli/src/tui/views/mod.rs new file mode 100644 index 0000000..16c2cf0 --- /dev/null +++ b/crates/runbox-cli/src/tui/views/mod.rs @@ -0,0 +1,9 @@ +//! TUI Views +//! +//! Contains different view implementations for the TUI. + +pub mod monitor; +pub mod logs; + +pub use monitor::{MonitorView, MonitorAction}; +pub use logs::LogView; diff --git a/crates/runbox-cli/src/tui/views/monitor.rs b/crates/runbox-cli/src/tui/views/monitor.rs new file mode 100644 index 0000000..703b0fb --- /dev/null +++ b/crates/runbox-cli/src/tui/views/monitor.rs @@ -0,0 +1,314 @@ +#![allow(dead_code)] +//! +//! Displays a list of running and recent processes with real-time updates. + +use anyhow::Result; +use chrono::Utc; +use crossterm::event::KeyEvent; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::Span, + widgets::{Row, Table, TableState}, + Frame, +}; +use runbox_core::{Run, RunStatus, Storage}; + +use crate::tui::event::KeyBindings; +use crate::tui::ui::{format_duration, format_time, render_help_bar, titled_block, truncate_str, Styles}; + +/// Process info for display +#[derive(Clone)] +pub struct ProcessInfo { + pub run: Run, + pub runtime_display: String, +} + +impl ProcessInfo { + pub fn from_run(run: Run) -> Self { + let runtime_display = if run.runtime.is_empty() { + "background".to_string() + } else { + run.runtime.clone() + }; + Self { run, runtime_display } + } + + /// Calculate runtime duration in seconds + pub fn runtime_seconds(&self) -> i64 { + let start = self.run.timeline.started_at; + let end = self.run.timeline.ended_at; + + match (start, end) { + (Some(s), Some(e)) => (e - s).num_seconds(), + (Some(s), None) => (Utc::now() - s).num_seconds(), + _ => -1, + } + } + + /// Get command display string + pub fn command_display(&self) -> String { + self.run.exec.argv.join(" ") + } + + /// Get started time display + pub fn started_display(&self) -> String { + self.run.timeline.started_at + .map(|t| format_time(&t)) + .unwrap_or_else(|| "-".to_string()) + } +} + +/// Monitor view state +pub struct MonitorView { + /// All processes + processes: Vec, + /// Table state for selection + table_state: TableState, + /// Current filter (None = show all) + status_filter: Option, + /// Last refresh timestamp + last_refresh: chrono::DateTime, +} + +impl MonitorView { + pub fn new() -> Self { + Self { + processes: Vec::new(), + table_state: TableState::default(), + status_filter: None, + last_refresh: Utc::now(), + } + } + + /// Refresh process list from storage + pub fn refresh(&mut self, storage: &Storage) -> Result<()> { + let runs = storage.list_runs(100)?; + self.processes = runs.into_iter().map(ProcessInfo::from_run).collect(); + + // Apply filter + if let Some(ref status) = self.status_filter { + self.processes.retain(|p| &p.run.status == status); + } + + // Maintain selection within bounds + if !self.processes.is_empty() { + let selected = self.table_state.selected().unwrap_or(0); + if selected >= self.processes.len() { + self.table_state.select(Some(self.processes.len() - 1)); + } else if self.table_state.selected().is_none() { + self.table_state.select(Some(0)); + } + } else { + self.table_state.select(None); + } + + self.last_refresh = Utc::now(); + Ok(()) + } + + /// Get the currently selected process + pub fn selected_process(&self) -> Option<&ProcessInfo> { + self.table_state.selected().and_then(|i| self.processes.get(i)) + } + + /// Get the run ID of the selected process + pub fn selected_run_id(&self) -> Option<&str> { + self.selected_process().map(|p| p.run.run_id.as_str()) + } + + /// Count running processes + pub fn running_count(&self) -> usize { + self.processes.iter().filter(|p| p.run.status == RunStatus::Running).count() + } + + /// Handle keyboard input + /// Returns: (should_quit, action_to_perform) + pub fn handle_key(&mut self, key: KeyEvent) -> (bool, Option) { + if KeyBindings::is_quit(key) { + return (true, None); + } + + if KeyBindings::is_up(key) { + self.select_previous(); + return (false, None); + } + + if KeyBindings::is_down(key) { + self.select_next(); + return (false, None); + } + + if KeyBindings::is_select(key) { + if let Some(process) = self.selected_process() { + return (false, Some(MonitorAction::ViewLogs(process.run.run_id.clone()))); + } + } + + if KeyBindings::is_stop(key) { + if let Some(process) = self.selected_process() { + if process.run.status == RunStatus::Running { + return (false, Some(MonitorAction::StopProcess(process.run.run_id.clone()))); + } + } + } + + if KeyBindings::is_attach(key) { + if let Some(process) = self.selected_process() { + return (false, Some(MonitorAction::AttachProcess(process.run.run_id.clone()))); + } + } + + if KeyBindings::is_refresh(key) { + return (false, Some(MonitorAction::Refresh)); + } + + (false, None) + } + + fn select_previous(&mut self) { + if self.processes.is_empty() { + return; + } + let i = match self.table_state.selected() { + Some(i) => { + if i > 0 { + i - 1 + } else { + self.processes.len() - 1 + } + } + None => 0, + }; + self.table_state.select(Some(i)); + } + + fn select_next(&mut self) { + if self.processes.is_empty() { + return; + } + let i = match self.table_state.selected() { + Some(i) => { + if i < self.processes.len() - 1 { + i + 1 + } else { + 0 + } + } + None => 0, + }; + self.table_state.select(Some(i)); + } + + /// Render the monitor view + pub fn render(&mut self, frame: &mut Frame, area: Rect) { + // Layout: title area, table, help bar + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), // Table (fills remaining space) + Constraint::Length(1), // Help bar + ]) + .split(area); + + self.render_table(frame, chunks[0]); + self.render_help(frame, chunks[1]); + } + + fn render_table(&mut self, frame: &mut Frame, area: Rect) { + let running_count = self.running_count(); + let title = format!( + " runbox monitor ({} running, {} total) ", + running_count, + self.processes.len() + ); + + let header_cells = ["SHORT", "STATUS", "RUNTIME", "STARTED", "COMMAND"] + .iter() + .map(|h| Span::styled(*h, Styles::header())); + let header = Row::new(header_cells).height(1); + + let rows: Vec = self.processes.iter().enumerate().map(|(idx, p)| { + let selected = self.table_state.selected() == Some(idx); + let pointer = if selected { "► " } else { " " }; + + let style = if selected { + Styles::selected() + } else { + Style::default() + }; + + let status_style = if selected { + Styles::selected() + } else { + Styles::status(&p.run.status) + }; + + let short_id = p.run.short_id(); + let runtime_str = format_duration(p.runtime_seconds()); + let cmd_display = truncate_str(&p.command_display(), 50); + + Row::new(vec![ + Span::styled(format!("{}{}", pointer, short_id), style), + Span::styled(format!("{:8}", p.run.status), status_style), + Span::styled(format!("{:10}", runtime_str), style), + Span::styled(format!("{:10}", p.started_display()), style), + Span::styled(cmd_display, style), + ]) + }).collect(); + + let widths = [ + Constraint::Length(12), // SHORT (with pointer) + Constraint::Length(10), // STATUS + Constraint::Length(12), // RUNTIME + Constraint::Length(12), // STARTED + Constraint::Min(20), // COMMAND (flexible) + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(titled_block(&title, true)) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + + frame.render_stateful_widget(table, area, &mut self.table_state); + } + + fn render_help(&self, frame: &mut Frame, area: Rect) { + let mut help_items = vec![ + ("↑/k", "Up"), + ("↓/j", "Down"), + ("Enter", "Logs"), + ]; + + // Add context-sensitive help + if let Some(process) = self.selected_process() { + if process.run.status == RunStatus::Running { + help_items.push(("s", "Stop")); + } + // Show attach for tmux/zellij runs + if matches!(process.run.runtime.as_str(), "tmux" | "zellij") { + help_items.push(("a", "Attach")); + } + } + + help_items.push(("r", "Refresh")); + help_items.push(("q", "Quit")); + + render_help_bar(frame, area, &help_items); + } +} + +impl Default for MonitorView { + fn default() -> Self { + Self::new() + } +} + +/// Actions that can be triggered from the monitor view +#[derive(Debug, Clone)] +pub enum MonitorAction { + ViewLogs(String), + StopProcess(String), + AttachProcess(String), + Refresh, +}