From edf95567a2e3011e13cea69286cc6c0748a51d77 Mon Sep 17 00:00:00 2001 From: proboscis Date: Tue, 20 Jan 2026 16:04:44 +0900 Subject: [PATCH] feat: add interactive TUI process monitor with ratatui Implements ISSUE-035 Phase 1-2: - Add 'runbox monitor' command for interactive process monitoring - Real-time auto-refresh (1 second interval) - Keyboard navigation (j/k, arrows, page up/down) - View logs for any process with Enter/l - Stop processes with s (graceful) or S (force) - Help overlay with ? key - Color-coded status indicators - Scrollable log viewer with follow mode Uses ratatui 0.26 and crossterm 0.27 for terminal UI. --- crates/runbox-cli/Cargo.toml | 4 + crates/runbox-cli/src/main.rs | 35 ++ crates/runbox-cli/src/tui/app.rs | 358 ++++++++++++++++++ crates/runbox-cli/src/tui/event.rs | 77 ++++ crates/runbox-cli/src/tui/mod.rs | 53 +++ crates/runbox-cli/src/tui/ui.rs | 339 +++++++++++++++++ crates/runbox-cli/src/tui/views/log_view.rs | 151 ++++++++ crates/runbox-cli/src/tui/views/mod.rs | 14 + .../runbox-cli/src/tui/views/process_list.rs | 116 ++++++ 9 files changed, 1147 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/log_view.rs create mode 100644 crates/runbox-cli/src/tui/views/mod.rs create mode 100644 crates/runbox-cli/src/tui/views/process_list.rs diff --git a/crates/runbox-cli/Cargo.toml b/crates/runbox-cli/Cargo.toml index cfb90d8..23ac81d 100644 --- a/crates/runbox-cli/Cargo.toml +++ b/crates/runbox-cli/Cargo.toml @@ -20,6 +20,10 @@ dirs = "5" dialoguer = "0.11" chrono = { version = "0.4", features = ["serde"] } log = "0.4" +# TUI dependencies - use older versions for Rust 1.87 compatibility +ratatui = "0.26" +crossterm = "0.27" +tokio = { version = "1", features = ["full"] } [features] # Enable tests that require tmux to be installed diff --git a/crates/runbox-cli/src/main.rs b/crates/runbox-cli/src/main.rs index 39cfe8c..de24b56 100644 --- a/crates/runbox-cli/src/main.rs +++ b/crates/runbox-cli/src/main.rs @@ -1,3 +1,4 @@ +mod tui; use anyhow::{bail, Context, Result}; use chrono::Utc; use clap::{Parser, Subcommand, ValueEnum}; @@ -219,6 +220,35 @@ RELATED COMMANDS: #[arg(short, long, default_value = "20")] limit: usize, }, + /// Interactive TUI process monitor + #[command(after_help = "\ +EXAMPLES: + # Launch interactive monitor + runbox monitor + +FEATURES: + - Real-time process list with auto-refresh + - Keyboard navigation (j/k, arrows) + - View logs for any process + - Stop processes directly + - Attach to tmux/zellij sessions + +KEYBINDINGS: + j/↓ Move selection down + k/↑ Move selection up + Enter/l View logs for selected process + s Stop selected process (SIGTERM) + S Force stop (SIGKILL) + a Attach to tmux/zellij session + r Refresh process list + ? Show help + q/Esc Quit + +RELATED COMMANDS: + runbox ps Static process list + runbox logs View logs directly + runbox stop Stop a process")] + Monitor, /// List all runnables (templates, replays, playlist items) in unified table #[command(after_help = "\ EXAMPLES: @@ -994,6 +1024,7 @@ fn main() -> Result<()> { &storage, command, runtime, dry_run, timeout, env_vars, cwd, no_git, ), Commands::Ps { status, all, limit } => cmd_ps(&storage, status, all, limit), + Commands::Monitor => cmd_monitor(&storage), Commands::List { r#type, playlist, @@ -1592,6 +1623,10 @@ fn cmd_run_replay( Ok(()) } +// === Monitor Command (TUI) === +fn cmd_monitor(storage: &Storage) -> Result<()> { + tui::run(storage) +} // === Ps Command === fn cmd_ps( storage: &Storage, diff --git a/crates/runbox-cli/src/tui/app.rs b/crates/runbox-cli/src/tui/app.rs new file mode 100644 index 0000000..6913f1a --- /dev/null +++ b/crates/runbox-cli/src/tui/app.rs @@ -0,0 +1,358 @@ +//! TUI Application state and main loop + +use super::event::{Event, EventHandler}; +use super::ui; +use super::views::{LogView, ProcessListView}; +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::prelude::*; +use runbox_core::{RuntimeRegistry, RunStatus, Storage}; +use std::io; +use std::time::Duration; + +/// Application mode/view +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppMode { + /// Process list view (main view) + ProcessList, + /// Log viewer for a specific run + LogViewer, + /// Help overlay + Help, +} + +/// Main TUI application state +pub struct App<'a> { + /// Storage backend + storage: &'a Storage, + /// Runtime registry for process management + runtime_registry: RuntimeRegistry, + /// Current application mode + mode: AppMode, + /// Whether the app should quit + should_quit: bool, + /// Process list view state + process_view: ProcessListView, + /// Log viewer state (when viewing logs) + log_view: Option, + /// Status message to display + status_message: Option, + /// Tick counter for auto-refresh + tick_count: u64, +} + +impl<'a> App<'a> { + /// Create a new App instance + pub fn new(storage: &'a Storage) -> Self { + Self { + storage, + runtime_registry: RuntimeRegistry::new(), + mode: AppMode::ProcessList, + should_quit: false, + process_view: ProcessListView::new(), + log_view: None, + status_message: None, + tick_count: 0, + } + } + + /// Run the main event loop + pub fn run(&mut self, terminal: &mut Terminal>) -> Result<()> { + // Create event handler with 500ms tick rate for auto-refresh + let event_handler = EventHandler::new(Duration::from_millis(500)); + + // Initial data load + self.refresh_data()?; + + loop { + // Draw UI + terminal.draw(|frame| self.draw(frame))?; + + // Handle events + match event_handler.next()? { + Event::Tick => { + self.tick_count += 1; + // Refresh data every 2 ticks (1 second) + if self.tick_count % 2 == 0 { + self.refresh_data()?; + } + } + Event::Key(key) => { + self.handle_key(key)?; + } + Event::Mouse(_) => { + // Mouse events not implemented yet + } + Event::Resize(_, _) => { + // Terminal will auto-redraw on resize + } + } + + if self.should_quit { + break; + } + } + + Ok(()) + } + + /// Refresh data from storage + fn refresh_data(&mut self) -> Result<()> { + // Reconcile run statuses + self.reconcile_runs()?; + + // Load runs + let runs = self.storage.list_runs(100)?; + self.process_view.update_runs(runs); + + // Update log view if active + if let Some(ref mut log_view) = self.log_view { + log_view.refresh(self.storage)?; + } + + Ok(()) + } + + /// Reconcile run statuses by checking if processes are still alive + fn reconcile_runs(&self) -> Result<()> { + let runs = self.storage.list_runs(usize::MAX)?; + + for run in runs { + if run.status != RunStatus::Running { + continue; + } + + if let Some(ref handle) = run.handle { + if let Some(adapter) = self.runtime_registry.get(&run.runtime) { + if !adapter.is_alive(handle) { + // Process is no longer running, update status + let _ = self.storage.save_run_if_status_with( + &run.run_id, + &[RunStatus::Running], + |current| { + current.status = RunStatus::Unknown; + current.reconcile_reason = + Some("Process not found (reconciled by TUI)".to_string()); + }, + ); + } + } + } + } + + Ok(()) + } + + /// Draw the current view + fn draw(&self, frame: &mut Frame) { + match self.mode { + AppMode::ProcessList => { + ui::draw_process_list(frame, &self.process_view, self.status_message.as_deref()); + } + AppMode::LogViewer => { + if let Some(ref log_view) = self.log_view { + ui::draw_log_view(frame, log_view); + } + } + AppMode::Help => { + ui::draw_process_list(frame, &self.process_view, self.status_message.as_deref()); + ui::draw_help_overlay(frame); + } + } + } + + /// Handle keyboard input + fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + // Global keybindings + match key.code { + KeyCode::Char('q') => { + if self.mode == AppMode::Help { + self.mode = AppMode::ProcessList; + } else if self.mode == AppMode::LogViewer { + self.mode = AppMode::ProcessList; + self.log_view = None; + } else { + self.should_quit = true; + } + return Ok(()); + } + KeyCode::Esc => { + if self.mode != AppMode::ProcessList { + self.mode = AppMode::ProcessList; + self.log_view = None; + } else { + self.should_quit = true; + } + return Ok(()); + } + KeyCode::Char('?') => { + self.mode = if self.mode == AppMode::Help { + AppMode::ProcessList + } else { + AppMode::Help + }; + return Ok(()); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + return Ok(()); + } + _ => {} + } + + // Mode-specific keybindings + match self.mode { + AppMode::ProcessList => self.handle_process_list_key(key), + AppMode::LogViewer => self.handle_log_viewer_key(key), + AppMode::Help => Ok(()), // Help mode only responds to q/Esc/? + } + } + + /// Handle keys in process list view + fn handle_process_list_key(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + // Navigation + KeyCode::Down | KeyCode::Char('j') => { + self.process_view.next(); + } + KeyCode::Up | KeyCode::Char('k') => { + self.process_view.previous(); + } + KeyCode::Home | KeyCode::Char('g') => { + self.process_view.first(); + } + KeyCode::End | KeyCode::Char('G') => { + self.process_view.last(); + } + KeyCode::PageDown => { + self.process_view.page_down(10); + } + KeyCode::PageUp => { + self.process_view.page_up(10); + } + + // Actions + KeyCode::Enter | KeyCode::Char('l') => { + // View logs for selected run + if let Some(run) = self.process_view.selected_run() { + self.log_view = Some(LogView::new(run.run_id.clone())); + if let Some(ref mut lv) = self.log_view { + lv.refresh(self.storage)?; + } + self.mode = AppMode::LogViewer; + } + } + KeyCode::Char('s') => { + // Stop selected run + if let Some(run) = self.process_view.selected_run() { + let run_id = run.run_id.clone(); + let short_id = run.short_id().to_string(); + let status = run.status.clone(); + + if status == RunStatus::Running { + self.stop_run(&run_id, false)?; + self.status_message = Some(format!("Stopped: {}", short_id)); + self.refresh_data()?; + } else { + self.status_message = + Some(format!("Cannot stop: {} ({})", short_id, status)); + } + } + } + KeyCode::Char('S') => { + // Force stop selected run + if let Some(run) = self.process_view.selected_run() { + let run_id = run.run_id.clone(); + let short_id = run.short_id().to_string(); + let status = run.status.clone(); + + if status == RunStatus::Running { + self.stop_run(&run_id, true)?; + self.status_message = Some(format!("Force stopped: {}", short_id)); + self.refresh_data()?; + } + } + } + KeyCode::Char('a') => { + // Attach to tmux/zellij session + if let Some(run) = self.process_view.selected_run() { + let runtime = run.runtime.clone(); + let short_id = run.short_id().to_string(); + + if runtime == "tmux" || runtime == "zellij" { + // For attach, we need to exit TUI mode first + self.status_message = Some(format!( + "Use 'runbox attach {}' to attach to the session", + short_id + )); + } else { + self.status_message = Some(format!( + "Attach only for tmux/zellij (current: {})", + if runtime.is_empty() { "none".to_string() } else { runtime } + )); + } + } + } + KeyCode::Char('r') => { + // Manual refresh + self.refresh_data()?; + self.status_message = Some("Refreshed".to_string()); + } + + _ => {} + } + Ok(()) + } + + /// Handle keys in log viewer + fn handle_log_viewer_key(&mut self, key: KeyEvent) -> Result<()> { + if let Some(ref mut log_view) = self.log_view { + match key.code { + // Scrolling + KeyCode::Down | KeyCode::Char('j') => log_view.scroll_down(1), + KeyCode::Up | KeyCode::Char('k') => log_view.scroll_up(1), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + log_view.scroll_down(20) + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + log_view.scroll_up(20) + } + KeyCode::PageDown => log_view.scroll_down(20), + KeyCode::PageUp => log_view.scroll_up(20), + KeyCode::Home | KeyCode::Char('g') => log_view.scroll_to_top(), + KeyCode::End | KeyCode::Char('G') => log_view.scroll_to_bottom(), + + // Follow mode + KeyCode::Char('f') => log_view.toggle_follow(), + + _ => {} + } + } + Ok(()) + } + + /// Stop a running process + fn stop_run(&self, run_id: &str, force: bool) -> Result<()> { + let run = self.storage.load_run(run_id)?; + + if let Some(ref handle) = run.handle { + if let Some(adapter) = self.runtime_registry.get(&run.runtime) { + adapter.stop(handle, force)?; + + // Update status + let _ = self.storage.save_run_if_status_with( + run_id, + &[RunStatus::Running, RunStatus::Pending], + |current| { + current.status = RunStatus::Killed; + if current.timeline.ended_at.is_none() { + current.timeline.ended_at = Some(chrono::Utc::now()); + } + }, + ); + } + } + + 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..81a9b9f --- /dev/null +++ b/crates/runbox-cli/src/tui/event.rs @@ -0,0 +1,77 @@ +//! Event handling for the TUI + +use anyhow::Result; +use crossterm::event::{self, KeyEvent, MouseEvent}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +/// Terminal events +#[derive(Debug)] +pub enum Event { + /// Terminal tick (for auto-refresh) + Tick, + /// Keyboard event + Key(KeyEvent), + /// Mouse event + #[allow(dead_code)] + Mouse(MouseEvent), + /// Terminal resize + #[allow(dead_code)] + Resize(u16, u16), +} + +/// Handles terminal events in a separate thread +pub struct EventHandler { + /// Event receiver + rx: mpsc::Receiver, + /// Event sender (kept for potential future use) + _tx: mpsc::Sender, +} + +impl EventHandler { + /// Create a new event handler with the given tick rate + pub fn new(tick_rate: Duration) -> Self { + let (tx, rx) = mpsc::channel(); + let event_tx = tx.clone(); + + // Spawn event polling thread + thread::spawn(move || { + loop { + // Poll for events with timeout + if event::poll(tick_rate).unwrap_or(false) { + match event::read() { + Ok(event::Event::Key(key)) => { + if event_tx.send(Event::Key(key)).is_err() { + break; + } + } + Ok(event::Event::Mouse(mouse)) => { + if event_tx.send(Event::Mouse(mouse)).is_err() { + break; + } + } + Ok(event::Event::Resize(w, h)) => { + if event_tx.send(Event::Resize(w, h)).is_err() { + break; + } + } + _ => {} + } + } else { + // Timeout - send tick event + if event_tx.send(Event::Tick).is_err() { + break; + } + } + } + }); + + Self { rx, _tx: tx } + } + + /// Get the next event (blocking) + pub fn next(&self) -> Result { + Ok(self.rx.recv()?) + } +} diff --git a/crates/runbox-cli/src/tui/mod.rs b/crates/runbox-cli/src/tui/mod.rs new file mode 100644 index 0000000..7031de6 --- /dev/null +++ b/crates/runbox-cli/src/tui/mod.rs @@ -0,0 +1,53 @@ +//! Terminal User Interface (TUI) for runbox +//! +//! Provides interactive terminal-based views for: +//! - Process monitoring (`runbox monitor`) +//! - Log viewing with scrollback and search +//! - Runnable browser with filtering +//! - Dashboard combining all views + +mod app; +mod event; +mod ui; +mod views; + +pub use app::App; + +use anyhow::Result; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::prelude::*; +use std::io; + +/// Initialize the terminal for TUI mode +pub fn init_terminal() -> Result>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +/// Restore the terminal to normal mode +pub fn restore_terminal(terminal: &mut Terminal>) -> Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} + +/// Run the TUI application +pub fn run(storage: &runbox_core::Storage) -> Result<()> { + let mut terminal = init_terminal()?; + let result = App::new(storage).run(&mut terminal); + restore_terminal(&mut terminal)?; + result +} diff --git a/crates/runbox-cli/src/tui/ui.rs b/crates/runbox-cli/src/tui/ui.rs new file mode 100644 index 0000000..c54deb3 --- /dev/null +++ b/crates/runbox-cli/src/tui/ui.rs @@ -0,0 +1,339 @@ +//! UI rendering functions + +use super::views::{LogView, ProcessListView}; +use ratatui::{ + prelude::*, + widgets::{ + Block, Borders, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, + ScrollbarState, Table, Wrap, + }, +}; +use runbox_core::RunStatus; + +/// Color scheme +mod colors { + use ratatui::style::Color; + + pub const RUNNING: Color = Color::Green; + pub const EXITED: Color = Color::Blue; + pub const FAILED: Color = Color::Red; + pub const KILLED: Color = Color::Yellow; + pub const PENDING: Color = Color::Gray; + pub const UNKNOWN: Color = Color::DarkGray; + + pub const SELECTED_BG: Color = Color::DarkGray; + pub const HEADER: Color = Color::Cyan; + pub const BORDER: Color = Color::Gray; + pub const TITLE: Color = Color::White; + pub const HELP_KEY: Color = Color::Yellow; + pub const STATUS_BAR: Color = Color::DarkGray; +} + +/// Get color for run status +fn status_color(status: &RunStatus) -> Color { + match status { + RunStatus::Running => colors::RUNNING, + RunStatus::Exited => colors::EXITED, + RunStatus::Failed => colors::FAILED, + RunStatus::Killed => colors::KILLED, + RunStatus::Pending => colors::PENDING, + RunStatus::Unknown => colors::UNKNOWN, + } +} + +/// Draw the process list view +pub fn draw_process_list(frame: &mut Frame, view: &ProcessListView, status_msg: Option<&str>) { + let area = frame.size(); + + // Layout: main content + status bar at bottom + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), // Main content + Constraint::Length(1), // Status bar + ]) + .split(area); + + let main_area = chunks[0]; + let status_area = chunks[1]; + + // Title with process count + let running_count = view.running_count(); + let title = format!( + " runbox monitor ({} running / {} total) ", + running_count, + view.total_count() + ); + + // Create table + let header = Row::new(vec![ + Cell::from("SHORT").style(Style::default().fg(colors::HEADER).bold()), + Cell::from("STATUS").style(Style::default().fg(colors::HEADER).bold()), + Cell::from("RUNTIME").style(Style::default().fg(colors::HEADER).bold()), + Cell::from("STARTED").style(Style::default().fg(colors::HEADER).bold()), + Cell::from("COMMAND").style(Style::default().fg(colors::HEADER).bold()), + ]) + .height(1); + + let rows: Vec = view + .runs() + .iter() + .enumerate() + .map(|(i, run)| { + let is_selected = i == view.selected_index(); + + // Format runtime + let runtime = if run.runtime.is_empty() { + "-".to_string() + } else { + run.runtime.clone() + }; + + // Format started time + let started = run + .timeline + .started_at + .map(|t| t.format("%H:%M:%S").to_string()) + .unwrap_or_else(|| "-".to_string()); + + // Format command + let cmd = run.exec.argv.join(" "); + let cmd_truncated = if cmd.len() > 40 { + format!("{}...", &cmd[..37]) + } else { + cmd + }; + + let cells = vec![ + Cell::from(format!("{}", if is_selected { "► " } else { " " }) + run.short_id()), + Cell::from(run.status.to_string()).style(Style::default().fg(status_color(&run.status))), + Cell::from(runtime), + Cell::from(started), + Cell::from(cmd_truncated), + ]; + + let row = Row::new(cells); + if is_selected { + row.style(Style::default().bg(colors::SELECTED_BG)) + } else { + row + } + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(12), // SHORT + Constraint::Length(10), // STATUS + Constraint::Length(12), // RUNTIME + Constraint::Length(10), // STARTED + Constraint::Min(20), // COMMAND + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(colors::BORDER)) + .title(title) + .title_style(Style::default().fg(colors::TITLE).bold()), + ); + + frame.render_widget(table, main_area); + + // Status bar with keybindings + let status_text = if let Some(msg) = status_msg { + format!( + " {} │ [Enter] Logs [s] Stop [a] Attach [r] Refresh [?] Help [q] Quit", + msg + ) + } else { + " [Enter] View logs [s] Stop [a] Attach [r] Refresh [?] Help [q] Quit".to_string() + }; + + let status_bar = Paragraph::new(status_text) + .style(Style::default().bg(colors::STATUS_BAR).fg(Color::White)); + + frame.render_widget(status_bar, status_area); +} + +/// Draw the log viewer +pub fn draw_log_view(frame: &mut Frame, view: &LogView) { + let area = frame.size(); + + // Layout: main content + status bar + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), // Main content + Constraint::Length(1), // Status bar + ]) + .split(area); + + let main_area = chunks[0]; + let status_area = chunks[1]; + + // Calculate visible height + let visible_height = main_area.height.saturating_sub(2) as usize; // -2 for borders + + // Title + let follow_indicator = if view.is_follow_mode() { "[FOLLOW]" } else { "" }; + let title = format!( + " Logs: {} ({}) {} ", + view.short_id(), + view.command(), + follow_indicator + ); + + // Create paragraph with log content + let visible_lines = view.visible_lines(visible_height); + let log_text: String = visible_lines.join("\n"); + + let log_paragraph = Paragraph::new(log_text) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(colors::BORDER)) + .title(title) + .title_style(Style::default().fg(colors::TITLE).bold()), + ) + .wrap(Wrap { trim: false }); + + frame.render_widget(log_paragraph, main_area); + + // Scrollbar + if view.line_count() > visible_height { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + + let mut scrollbar_state = ScrollbarState::new(view.line_count()) + .position(view.scroll_position()); + + let margin = Margin { + horizontal: 0, + vertical: 1, + }; + + frame.render_stateful_widget( + scrollbar, + main_area.inner(&margin), + &mut scrollbar_state, + ); + } + + // Status bar + let position_info = format!( + " Line {}/{} ", + view.scroll_position() + 1, + view.line_count() + ); + + let status_text = format!( + "{}│ [j/k] Scroll [g/G] Top/Bottom [f] Follow [/] Search [q] Back", + position_info + ); + + let status_bar = Paragraph::new(status_text) + .style(Style::default().bg(colors::STATUS_BAR).fg(Color::White)); + + frame.render_widget(status_bar, status_area); +} + +/// Draw help overlay +pub fn draw_help_overlay(frame: &mut Frame) { + let area = frame.size(); + + // Calculate center popup area + let popup_width = 60; + let popup_height = 20; + let popup_area = centered_rect(popup_width, popup_height, area); + + // Clear the popup area + frame.render_widget(Clear, popup_area); + + let help_text = vec![ + Line::from(vec![ + Span::styled("Navigation", Style::default().bold().fg(colors::HEADER)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" j/↓ ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Move down"), + ]), + Line::from(vec![ + Span::styled(" k/↑ ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Move up"), + ]), + Line::from(vec![ + Span::styled(" g ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Go to top"), + ]), + Line::from(vec![ + Span::styled(" G ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Go to bottom"), + ]), + Line::from(vec![ + Span::styled(" PgUp/Dn ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Page up/down"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Actions", Style::default().bold().fg(colors::HEADER)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" Enter/l ", Style::default().fg(colors::HELP_KEY)), + Span::raw("View logs"), + ]), + Line::from(vec![ + Span::styled(" s ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Stop process (SIGTERM)"), + ]), + Line::from(vec![ + Span::styled(" S ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Force stop (SIGKILL)"), + ]), + Line::from(vec![ + Span::styled(" a ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Attach (tmux/zellij only)"), + ]), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Refresh"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" ? ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Toggle help"), + ]), + Line::from(vec![ + Span::styled(" q/Esc ", Style::default().fg(colors::HELP_KEY)), + Span::raw("Quit / Back"), + ]), + ]; + + let help = Paragraph::new(help_text).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(colors::HEADER)) + .title(" Help ") + .title_style(Style::default().fg(colors::TITLE).bold()), + ); + + frame.render_widget(help, popup_area); +} + +/// Helper to create a centered rectangle +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let horizontal_margin = (area.width.saturating_sub(width)) / 2; + let vertical_margin = (area.height.saturating_sub(height)) / 2; + + Rect { + x: area.x + horizontal_margin, + y: area.y + vertical_margin, + width: width.min(area.width), + height: height.min(area.height), + } +} diff --git a/crates/runbox-cli/src/tui/views/log_view.rs b/crates/runbox-cli/src/tui/views/log_view.rs new file mode 100644 index 0000000..cb2036c --- /dev/null +++ b/crates/runbox-cli/src/tui/views/log_view.rs @@ -0,0 +1,151 @@ +//! Log viewer with scrollback and search + +use anyhow::Result; +use runbox_core::Storage; +use std::fs::File; +use std::io::{BufRead, BufReader}; + +/// State for the log viewer +pub struct LogView { + /// Run ID being viewed + run_id: String, + /// Run short ID for display + short_id: String, + /// Command being run + command: String, + /// Log lines + lines: Vec, + /// Current scroll position (line index at top of view) + scroll_position: usize, + /// Whether to follow new output (auto-scroll to bottom) + follow_mode: bool, + /// Search query (if searching) - for future use + #[allow(dead_code)] + search_query: Option, + /// Search result positions - for future use + #[allow(dead_code)] + search_results: Vec, + /// Current search result index - for future use + #[allow(dead_code)] + current_search_result: usize, +} + +impl LogView { + /// Create a new log view for a run + pub fn new(run_id: String) -> Self { + let short_id = if run_id.len() >= 12 { + run_id[4..12].to_string() + } else { + run_id.clone() + }; + + Self { + run_id, + short_id, + command: String::new(), + lines: Vec::new(), + scroll_position: 0, + follow_mode: true, + search_query: None, + search_results: Vec::new(), + current_search_result: 0, + } + } + + /// Refresh log content from storage + pub fn refresh(&mut self, storage: &Storage) -> Result<()> { + // Load run info + let run = storage.load_run(&self.run_id)?; + self.command = run.exec.argv.join(" "); + + // Get log path + let log_path = if let Some(ref log_ref) = run.log_ref { + log_ref.path.clone() + } else { + storage.log_path(&self.run_id) + }; + + // Read log file + if log_path.exists() { + let file = File::open(&log_path)?; + let reader = BufReader::new(file); + self.lines = reader.lines().filter_map(|l| l.ok()).collect(); + + // Auto-scroll to bottom if in follow mode + if self.follow_mode && !self.lines.is_empty() { + // Will be adjusted in render based on visible height + self.scroll_position = self.lines.len().saturating_sub(1); + } + } else { + self.lines = vec!["[No log file found]".to_string()]; + } + + Ok(()) + } + + /// Get the short ID + pub fn short_id(&self) -> &str { + &self.short_id + } + + /// Get the command + pub fn command(&self) -> &str { + &self.command + } + + /// Get scroll position + pub fn scroll_position(&self) -> usize { + self.scroll_position + } + + /// Check if follow mode is enabled + pub fn is_follow_mode(&self) -> bool { + self.follow_mode + } + + /// Toggle follow mode + pub fn toggle_follow(&mut self) { + self.follow_mode = !self.follow_mode; + if self.follow_mode && !self.lines.is_empty() { + self.scroll_position = self.lines.len().saturating_sub(1); + } + } + + /// Scroll down by n lines + pub fn scroll_down(&mut self, n: usize) { + self.follow_mode = false; + self.scroll_position = (self.scroll_position + n).min(self.lines.len().saturating_sub(1)); + } + + /// Scroll up by n lines + pub fn scroll_up(&mut self, n: usize) { + self.follow_mode = false; + self.scroll_position = self.scroll_position.saturating_sub(n); + } + + /// Scroll to top + pub fn scroll_to_top(&mut self) { + self.follow_mode = false; + self.scroll_position = 0; + } + + /// Scroll to bottom + pub fn scroll_to_bottom(&mut self) { + self.follow_mode = true; + if !self.lines.is_empty() { + self.scroll_position = self.lines.len().saturating_sub(1); + } + } + + /// Get visible lines for rendering + pub fn visible_lines(&self, height: usize) -> &[String] { + let start = self.scroll_position; + let end = (start + height).min(self.lines.len()); + &self.lines[start..end] + } + + /// Get total line count + pub fn line_count(&self) -> usize { + self.lines.len() + } +} 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..1371568 --- /dev/null +++ b/crates/runbox-cli/src/tui/views/mod.rs @@ -0,0 +1,14 @@ +//! View components for the TUI + +mod log_view; +mod process_list; + +pub use log_view::LogView; +pub use process_list::ProcessListView; + +/// Trait for views that can be rendered +#[allow(dead_code)] +pub trait View { + /// Update the view's data + fn refresh(&mut self, storage: &runbox_core::Storage) -> anyhow::Result<()>; +} diff --git a/crates/runbox-cli/src/tui/views/process_list.rs b/crates/runbox-cli/src/tui/views/process_list.rs new file mode 100644 index 0000000..bc9939f --- /dev/null +++ b/crates/runbox-cli/src/tui/views/process_list.rs @@ -0,0 +1,116 @@ +//! Process list view for monitoring running and recent processes + +use runbox_core::Run; + +/// State for the process list view +pub struct ProcessListView { + /// List of runs + runs: Vec, + /// Currently selected index + selected: usize, + /// Scroll offset for rendering - for future use with large lists + #[allow(dead_code)] + scroll_offset: usize, +} + +impl ProcessListView { + /// Create a new process list view + pub fn new() -> Self { + Self { + runs: Vec::new(), + selected: 0, + scroll_offset: 0, + } + } + + /// Update the runs list + pub fn update_runs(&mut self, runs: Vec) { + // Try to keep selection on the same run if possible + let prev_selected_id = self.selected_run().map(|r| r.run_id.clone()); + + self.runs = runs; + + // Restore selection or clamp to valid range + if let Some(prev_id) = prev_selected_id { + if let Some(idx) = self.runs.iter().position(|r| r.run_id == prev_id) { + self.selected = idx; + } else { + self.selected = self.selected.min(self.runs.len().saturating_sub(1)); + } + } else { + self.selected = 0; + } + } + + /// Get the currently selected run + pub fn selected_run(&self) -> Option<&Run> { + self.runs.get(self.selected) + } + + /// Get all runs + pub fn runs(&self) -> &[Run] { + &self.runs + } + + /// Get the selected index + pub fn selected_index(&self) -> usize { + self.selected + } + + /// Move selection to next item + pub fn next(&mut self) { + if !self.runs.is_empty() { + self.selected = (self.selected + 1).min(self.runs.len() - 1); + } + } + + /// Move selection to previous item + pub fn previous(&mut self) { + if !self.runs.is_empty() { + self.selected = self.selected.saturating_sub(1); + } + } + + /// Move to first item + pub fn first(&mut self) { + self.selected = 0; + } + + /// Move to last item + pub fn last(&mut self) { + if !self.runs.is_empty() { + self.selected = self.runs.len() - 1; + } + } + + /// Page down + pub fn page_down(&mut self, page_size: usize) { + if !self.runs.is_empty() { + self.selected = (self.selected + page_size).min(self.runs.len() - 1); + } + } + + /// Page up + pub fn page_up(&mut self, page_size: usize) { + self.selected = self.selected.saturating_sub(page_size); + } + + /// Count of running processes + pub fn running_count(&self) -> usize { + self.runs + .iter() + .filter(|r| r.status == runbox_core::RunStatus::Running) + .count() + } + + /// Total count + pub fn total_count(&self) -> usize { + self.runs.len() + } +} + +impl Default for ProcessListView { + fn default() -> Self { + Self::new() + } +}