diff --git a/README.md b/README.md index 36051d6..3191e10 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Pre-built binaries for all platforms are available on the [GitHub Releases](http ```bash abtop # Launch TUI abtop --once # Print snapshot and exit +abtop --json # Print one JSON snapshot and exit (for scripts/tools) abtop --setup # Install rate limit collection hook abtop --theme dracula # Launch with a specific theme ``` @@ -152,9 +153,42 @@ When `language` is unset, abtop auto-detects from `LANG` — any value starting | `q` | Quit | | `r` | Force refresh | +## Library / JSON snapshot + +abtop is also a library crate, so local tools can reuse its data-collection +layer in-process — no re-scanning, no subprocesses — and serialize the same +state the TUI renders. + +```bash +abtop --json # one-shot JSON snapshot for scripts +``` + +For long-running consumers, build an `App`, refresh it with +`App::tick_no_summaries()` (which never spawns `claude --print`, so it doesn't +touch your Claude quota), and call `App::to_snapshot(interval_ms)` to get a +JSON-serializable [`Snapshot`]: + +```rust,no_run +use abtop::app::App; +use abtop::{config, theme::Theme}; + +let cfg = config::load_config(); +let mut app = App::new_with_config_and_claude_dirs( + Theme::default(), &cfg.hidden_agents, cfg.panels, &cfg.claude_config_dirs, +); +app.tick_no_summaries(); +let json = serde_json::to_string(&app.to_snapshot(2_000)).unwrap(); +``` + +`App` is not `Send` (it owns the collectors), so keep it on one thread and pass +the serialized JSON elsewhere. [abtop-web-ui](https://github.com/XKHoshizora/abtop-web-ui) +is a reference consumer: a local-first web dashboard built on exactly this API. + ## Privacy -abtop reads local files and local process/open-file metadata only. No API keys, no auth. Tool names and file paths are shown in the UI, but file contents and prompt text are never displayed. Session summaries are generated via `claude --print`, which makes its own API call — this is the only indirect network usage. +abtop reads local files and local process/open-file metadata only. No API keys, no auth. In the TUI and `--once` output, tool names and file paths are shown, but file contents and prompt text are never displayed. Session summaries are generated via `claude --print`, which makes its own API call — this is the only indirect network usage. + +The JSON snapshot includes richer local dashboard data, including `summary`, `chat_messages`, working directories, config roots, tool-call previews, child process commands, token counts, and port metadata. Chat text is bounded and redacted by the collectors, but it is still derived from local transcripts and may contain sensitive project context. Treat JSON snapshots as local/private data and avoid writing them to shared logs or exposing them on a network without your own access controls. ## Acknowledgements diff --git a/src/app.rs b/src/app.rs index e59ac78..23783a8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -490,7 +490,21 @@ impl App { self.status_msg = Some((msg, Instant::now())); } + /// Full refresh used by the TUI: collect monitored data, then generate and + /// retry session summaries. Equivalent to [`App::tick_no_summaries`] followed + /// by [`App::drain_and_retry_summaries`]. pub fn tick(&mut self) { + self.tick_no_summaries(); + self.drain_and_retry_summaries(); + } + + /// Refresh all monitored data WITHOUT spawning background summary jobs. + /// + /// `tick` additionally calls [`App::drain_and_retry_summaries`], which + /// shells out to `claude --print` to generate session titles. Headless + /// consumers (e.g. the web snapshot API) call this variant so they never + /// spawn subprocesses or consume the user's Claude quota. + pub fn tick_no_summaries(&mut self) { self.collector.set_mcp_suppress(self.mcp_suppress_sessions); self.sessions = self.collector.collect(); self.orphan_ports = self.collector.orphan_ports.clone(); @@ -532,8 +546,6 @@ impl App { } promote_waiting_to_rate_limited(&mut self.sessions, &self.rate_limits); - - self.drain_and_retry_summaries(); } /// Drain completed summary results and spawn retries. Does NOT recollect diff --git a/src/collector/claude.rs b/src/collector/claude.rs index 1051bac..39a2bc5 100644 --- a/src/collector/claude.rs +++ b/src/collector/claude.rs @@ -790,6 +790,12 @@ impl ClaudeCollector { } } +impl Default for ClaudeCollector { + fn default() -> Self { + Self::with_configured_dirs(Vec::new()) + } +} + impl super::AgentCollector for ClaudeCollector { fn collect(&mut self, shared: &super::SharedProcessData) -> Vec { self.collect_sessions(shared) diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 76caa2b..3c7b4c0 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -843,6 +843,12 @@ impl CodexCollector { } } +impl Default for CodexCollector { + fn default() -> Self { + Self::new() + } +} + impl super::AgentCollector for CodexCollector { fn collect(&mut self, shared: &super::SharedProcessData) -> Vec { self.collect_sessions(shared) diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 993d4c7..7ef1523 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -377,6 +377,12 @@ LIMIT {};"#, } } +impl Default for OpenCodeCollector { + fn default() -> Self { + Self::new() + } +} + impl super::AgentCollector for OpenCodeCollector { fn collect(&mut self, shared: &super::SharedProcessData) -> Vec { self.collect_sessions(shared) diff --git a/src/host_info.rs b/src/host_info.rs index 8849538..882ec71 100644 --- a/src/host_info.rs +++ b/src/host_info.rs @@ -4,7 +4,9 @@ //! callers should treat absence as "metrics unavailable" and render a graceful //! fallback. -#[derive(Debug, Clone, Copy)] +use serde::Serialize; + +#[derive(Debug, Clone, Copy, Serialize)] pub struct HostMetrics { /// Aggregate CPU usage in percent (0.0 - 100.0). Computed across all cores. pub cpu_pct: f64, @@ -141,7 +143,7 @@ fn sample_load() -> Option { } /// Aggregate per-session metrics into a single agent-wide summary. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, Serialize)] pub struct AgentAggregate { pub mem_mb: u64, /// Average context window fill across active sessions (0.0 - 100.0). diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ab42504 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,551 @@ +//! abtop — AI agent monitor. +//! +//! This crate is both a binary (the TUI, entered via [`run`]) and a library. +//! The library surface exists so a separate local tool (e.g. a web UI) can +//! reuse the data-collection layer in-process and serialize it via +//! [`snapshot::Snapshot`] / [`app::App::to_snapshot`], without reimplementing +//! session discovery and without depending on the terminal frontend. +//! +//! # Public API for library consumers +//! +//! The stable surface for in-process consumers is [`app`] (notably +//! [`App::to_snapshot`](app::App::to_snapshot) and +//! [`App::tick_no_summaries`](app::App::tick_no_summaries)), [`snapshot`], +//! [`config`], [`demo`], [`host_info`], and the data types in [`model`]. The +//! [`collector`], [`locale`], [`setup`], [`theme`], and [`ui`] modules are +//! published mainly to support the bundled TUI binary and may change without a +//! semver-major bump — depend on them at your own risk. +//! +//! Enum wire formats are part of the snapshot contract: variants such as +//! [`model::SessionStatus`] serialize as their CamelCase names (`"Thinking"`, +//! `"Executing"`, …) and chat roles serialize as `"user"` / `"assistant"`. +//! These strings are stable and won't be renamed without a major version bump. +//! +//! # Threading model +//! +//! [`App`] is **not** `Send`: it owns boxed collector trait objects +//! and must stay on the thread that created it. Don't move it between threads +//! or share it with request handlers — instead, run the collector loop on one +//! thread, serialize each [`snapshot::Snapshot`] to JSON, and hand the *string* +//! to other threads. +//! +//! # Typical usage +//! +//! ```no_run +//! use abtop::app::App; +//! use abtop::{config, theme::Theme}; +//! +//! let cfg = config::load_config(); +//! let mut app = App::new_with_config_and_claude_dirs( +//! Theme::default(), +//! &cfg.hidden_agents, +//! cfg.panels, +//! &cfg.claude_config_dirs, +//! ); +//! loop { +//! app.tick_no_summaries(); // refresh without spawning `claude --print` +//! let snap = app.to_snapshot(2_000); // pure read → JSON-friendly DTO +//! let json = serde_json::to_string(&snap).unwrap(); +//! // ... serve `json`, sleep for the interval, repeat ... +//! # break; +//! } +//! ``` + +pub mod app; +pub mod collector; +pub mod config; +pub mod demo; +pub mod host_info; +pub mod locale; +pub mod model; +pub mod setup; +pub mod snapshot; +pub mod theme; +pub mod ui; + +use app::{App, JumpOutcome}; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton, + MouseEvent, MouseEventKind, +}; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use crossterm::ExecutableCommand; +use ratatui::prelude::*; +use std::io::{self, stdout}; +use std::time::Duration; + +/// Construct a headless `App` from loaded config + theme. Shared by the +/// `--json` and `--once` entry points. +fn build_app(theme: theme::Theme, cfg: &config::AppConfig) -> App { + App::new_with_config_and_claude_dirs( + theme, + &cfg.hidden_agents, + cfg.panels, + &cfg.claude_config_dirs, + ) +} + +pub fn run() -> io::Result<()> { + // --version / -V flag: print version and exit + if std::env::args().any(|a| a == "--version" || a == "-V") { + println!("abtop {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + // --update flag: self-update via GitHub releases installer + if std::env::args().any(|a| a == "--update") { + return run_update(); + } + + // --setup flag: configure StatusLine hook and exit + if std::env::args().any(|a| a == "--setup") { + setup::run_setup(); + return Ok(()); + } + + // Load config once; it drives both the default theme and the hidden-agents list. + let cfg = config::load_config(); + + // --theme flag > config file > default + let initial_theme = std::env::args() + .position(|a| a == "--theme") + .map(|pos| { + let val = std::env::args().nth(pos + 1); + match val { + Some(name) if !name.starts_with('-') => name, + Some(name) => { + eprintln!("--theme requires a theme name, got '{}'", name); + eprintln!("available: {}", theme::THEME_NAMES.join(", ")); + std::process::exit(1); + } + None => { + eprintln!("--theme requires a theme name"); + eprintln!("available: {}", theme::THEME_NAMES.join(", ")); + std::process::exit(1); + } + } + }) + .map(|name| { + theme::Theme::by_name(&name).unwrap_or_else(|| { + eprintln!( + "unknown theme '{}'. available: {}", + name, + theme::THEME_NAMES.join(", ") + ); + std::process::exit(1); + }) + }) + .or_else(|| theme::Theme::by_name(&cfg.theme)); + + let demo_mode = std::env::args().any(|a| a == "--demo"); + let exit_on_jump = std::env::args().any(|a| a == "--exit-on-jump"); + + // --json flag: print a machine-readable JSON snapshot and exit. + // Single tick, no summary subprocesses. Useful for scripting and as a + // manual check of the web snapshot API; the web tool uses the library + // `App::to_snapshot` directly rather than shelling out to this. + if std::env::args().any(|a| a == "--json") { + let mut app = build_app(initial_theme.unwrap_or_default(), &cfg); + if demo_mode { + demo::populate_demo(&mut app); + } else { + app.tick_no_summaries(); + } + match serde_json::to_string_pretty(&app.to_snapshot(2000)) { + Ok(json) => { + println!("{}", json); + return Ok(()); + } + Err(e) => { + eprintln!("failed to serialize snapshot: {}", e); + std::process::exit(1); + } + } + } + + // --once flag: print snapshot and exit + if std::env::args().any(|a| a == "--once") { + let mut app = build_app(initial_theme.unwrap_or_default(), &cfg); + if demo_mode { + demo::populate_demo(&mut app); + } else { + app.tick(); + // Wait for summaries: retry-aware budget (up to 30s total to allow 2 × 10s attempts + slack) + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while std::time::Instant::now() < deadline { + app.drain_and_retry_summaries(); + if !app.has_pending_summaries() && !app.has_retryable_summaries() { + break; + } + std::thread::sleep(Duration::from_millis(500)); + } + } + print_snapshot(&app); + return Ok(()); + } + + // Setup terminal + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + stdout().execute(EnableMouseCapture)?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; + + let app_result = run_app( + &mut terminal, + demo_mode, + initial_theme, + exit_on_jump, + &cfg.hidden_agents, + cfg.panels, + &cfg.claude_config_dirs, + ); + + // Always attempt both cleanup steps regardless of app result + let r1 = stdout().execute(DisableMouseCapture).map(|_| ()); + let r2 = disable_raw_mode(); + let r3 = stdout().execute(LeaveAlternateScreen).map(|_| ()); + + // Return app error first, then cleanup errors + app_result.and(r1).and(r2).and(r3) +} + +fn run_app( + terminal: &mut Terminal>, + demo_mode: bool, + initial_theme: Option, + exit_on_jump: bool, + hidden_agents: &[String], + panels: config::PanelVisibility, + claude_config_dirs: &[std::path::PathBuf], +) -> io::Result<()> { + let mut app = App::new_with_config_and_claude_dirs( + initial_theme.unwrap_or_default(), + hidden_agents, + panels, + claude_config_dirs, + ); + if demo_mode { + demo::populate_demo(&mut app); + } else { + app.tick(); + } + + let mut last_tick = std::time::Instant::now(); + let tick_interval = Duration::from_secs(2); + let render_interval = Duration::from_millis(500); + + loop { + terminal.draw(|f| ui::draw(f, &app))?; + + // Poll at 500ms for smooth animations; data tick every 2s + let had_input = if event::poll(render_interval)? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if app.help_open { + // Any key dismisses help. + app.help_open = false; + } else if app.view_open { + match key.code { + KeyCode::Esc | KeyCode::Char('v') => app.view_open = false, + KeyCode::Char('T') => app.tree_view = !app.tree_view, + KeyCode::Char('l') => app.toggle_timeline(), + KeyCode::Char('f') => app.toggle_file_audit(), + KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), + KeyCode::Char('M') => app.toggle_mcp_session_suppression(), + KeyCode::Char('t') => app.cycle_theme(), + _ => {} + } + } else if app.config_open { + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('c') => { + app.toggle_config() + } + KeyCode::Down | KeyCode::Char('j') => app.config_select_next(), + KeyCode::Up | KeyCode::Char('k') => app.config_select_prev(), + KeyCode::Enter | KeyCode::Char(' ') => app.config_toggle_selected(), + _ => {} + } + } else if app.filter_active { + match key.code { + KeyCode::Esc => app.clear_filter(), + KeyCode::Enter => app.filter_active = false, + KeyCode::Backspace => app.filter_pop(), + KeyCode::Down => app.select_next(), + KeyCode::Up => app.select_prev(), + KeyCode::Char(c) => app.filter_push(c), + _ => {} + } + } else { + match key.code { + KeyCode::Char('q') => app.quit(), + KeyCode::Char('r') if !demo_mode => app.tick(), + KeyCode::Down | KeyCode::Char('j') => app.select_next(), + KeyCode::Up | KeyCode::Char('k') => app.select_prev(), + KeyCode::Right | KeyCode::Tab => app.select_next_narrow_tab(), + KeyCode::Left | KeyCode::BackTab => app.select_prev_narrow_tab(), + KeyCode::Char('w') => app.set_narrow_tab(app::NarrowTab::Work), + KeyCode::Char('u') => app.set_narrow_tab(app::NarrowTab::Usage), + KeyCode::Char('s') => app.set_narrow_tab(app::NarrowTab::System), + KeyCode::Char('+') | KeyCode::Char('=') => { + app.maximize_active_narrow_section() + } + KeyCode::Char('-') => app.restore_narrow_sections(), + KeyCode::Char('x') if !demo_mode => app.kill_selected(), + KeyCode::Char('X') if !demo_mode => app.kill_orphan_ports(), + KeyCode::Char('t') => app.cycle_theme(), + KeyCode::Char('T') => app.tree_view = !app.tree_view, + KeyCode::Char('l') | KeyCode::Char('L') => app.toggle_timeline(), + KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), + KeyCode::Char('M') => app.toggle_mcp_session_suppression(), + KeyCode::Char('c') => app.toggle_config(), + KeyCode::Char('v') => app.toggle_view_menu(), + KeyCode::Char('?') => app.toggle_help(), + KeyCode::Char('/') => app.filter_active = true, + KeyCode::Esc if !app.filter_text.is_empty() => app.clear_filter(), + KeyCode::Char('f') | KeyCode::Char('F') => app.toggle_file_audit(), + KeyCode::Enter if !demo_mode => match app.jump_to_session() { + JumpOutcome::Jumped if exit_on_jump => app.quit(), + JumpOutcome::Failed(msg) => app.set_status(msg), + JumpOutcome::Jumped | JumpOutcome::NoOp => {} + }, + _ => {} + } + } + } + Event::Mouse(mouse) => { + let size = terminal.size()?; + let area = Rect::new(0, 0, size.width, size.height); + handle_mouse_event(&mut app, mouse, area); + } + _ => {} + } + true + } else { + false + }; + + if demo_mode { + // Rotate token rates to animate the sparkline + if let Some(front) = app.token_rates.pop_front() { + app.token_rates.push_back(front); + } + } else if !had_input && last_tick.elapsed() >= tick_interval { + // Data tick every 2s — skip when handling input to avoid lag + app.tick(); + last_tick = std::time::Instant::now(); + } + + if app.should_quit { + break; + } + } + + Ok(()) +} + +fn handle_mouse_event(app: &mut App, mouse: MouseEvent, area: Rect) { + if app.help_open || app.view_open || app.config_open || app.filter_active { + return; + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(target) = ui::click_target(app, area, mouse.column, mouse.row) { + match target { + ui::ClickTarget::NarrowTab(tab) => app.set_narrow_tab(tab), + ui::ClickTarget::NarrowSection(section) => { + app.set_active_narrow_section(section); + } + ui::ClickTarget::NarrowZoom(section) => { + app.toggle_narrow_section_zoom(section); + } + ui::ClickTarget::Session(index) => { + app.select_session(index); + app.set_active_narrow_section(app::NarrowSection::Sessions); + } + ui::ClickTarget::KillOrphanPorts => { + app.set_active_narrow_section(app::NarrowSection::Ports); + app.kill_orphan_ports(); + } + } + } + } + MouseEventKind::ScrollDown => app.select_next(), + MouseEventKind::ScrollUp => app.select_prev(), + MouseEventKind::ScrollRight => app.select_next_narrow_tab(), + MouseEventKind::ScrollLeft => app.select_prev_narrow_tab(), + _ => {} + } +} + +/// Strip control characters (including ANSI escapes) and Unicode bidi +/// overrides from a string for safe terminal output. Defeats CVE-2021-42574 +/// (Trojan Source) style attacks via RTLO/LRO/PDF/isolate characters. +fn sanitize_output(s: &str) -> String { + s.chars() + .filter(|c| { + !c.is_control() + && !matches!(*c, + '\u{202A}'..='\u{202E}' + | '\u{2066}'..='\u{2069}' + | '\u{200E}' + | '\u{200F}') + }) + .collect() +} + +fn print_snapshot(app: &App) { + println!( + "abtop — {} sessions, {} mcp servers\n", + app.sessions.len(), + app.mcp_servers.len() + ); + if !app.mcp_servers.is_empty() { + let now = std::time::SystemTime::now(); + for server in &app.mcp_servers { + let active = server.active_count(now, collector::mcp::ACTIVE_MTIME_SECS); + let total = server.rollouts.len(); + let last_age = server + .latest_mtime() + .and_then(|m| now.duration_since(m).ok()) + .map(|d| { + if d.as_secs() < 60 { + format!("{}s", d.as_secs()) + } else if d.as_secs() < 3600 { + format!("{}m", d.as_secs() / 60) + } else { + format!("{}h", d.as_secs() / 3600) + } + }) + .unwrap_or_else(|| "—".to_string()); + let profile = server.profile.as_deref().unwrap_or("default"); + println!( + " mcp pid={} parent={} profile={:<16} active={}/{} last={}", + server.pid, server.parent_cli, profile, active, total, last_age + ); + } + println!(); + } + for session in &app.sessions { + let status = match &session.status { + model::SessionStatus::Thinking => "◉ Think", + model::SessionStatus::Executing => "● Exec", + model::SessionStatus::Waiting => "◌ Wait", + model::SessionStatus::Unknown => "? Unknown", + model::SessionStatus::RateLimited => "⏳ Rate", + model::SessionStatus::Done => "✓ Done", + }; + let sid_short = if session.session_id.len() >= 7 { + &session.session_id[..7] + } else { + &session.session_id + }; + let project_label = format!("{}({})", session.project_name, sid_short); + let summary = sanitize_output(&app.session_summary(session)); + println!( + " {} {:<20} {} {} {:<10} CTX:{:>3.0}% Tok:{} Mem:{}M {}", + session.pid, + sanitize_output(&project_label), + summary, + status, + session.model.replace("claude-", ""), + session.context_percent, + fmt_tok(session.total_tokens()), + session.mem_mb, + session.elapsed_display(), + ); + if let Some(task) = session.current_tasks.last() { + println!(" └─ {}", sanitize_output(task)); + } + for child in &session.children { + let port = child.port.map(|p| format!(":{}", p)).unwrap_or_default(); + println!( + " {} {} {}K {}", + child.pid, + sanitize_output( + &child + .command + .split_whitespace() + .take(3) + .collect::>() + .join(" ") + ), + child.mem_kb / 1024, + port, + ); + } + } +} + +fn run_update() -> io::Result<()> { + let current = env!("CARGO_PKG_VERSION"); + println!("abtop v{current} — checking for updates...\n"); + + // Download to a private temp file (O_EXCL + random suffix) so a local + // attacker can't pre-place a symlink or swap the file mid-run. + let tmp = tempfile::Builder::new() + .prefix("abtop-installer-") + .suffix(".sh") + .tempfile()?; + let installer_path = tmp.path().to_path_buf(); + + let dl_status = std::process::Command::new("curl") + .args([ + "--proto", + "=https", + "--tlsv1.2", + "-LsSf", + "https://github.com/graykode/abtop/releases/latest/download/abtop-installer.sh", + "-o", + ]) + .arg(&installer_path) + .status()?; + + if !dl_status.success() { + eprintln!("\nDownload failed. You can also update manually:"); + eprintln!(" cargo install abtop --force"); + std::process::exit(1); + } + + // Show checksum so the user can verify if desired. + // macOS ships `shasum` (Perl) by default, Linux ships `sha256sum` (coreutils). + let checksum_shown = std::process::Command::new("shasum") + .args(["-a", "256"]) + .arg(&installer_path) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !checksum_shown { + let _ = std::process::Command::new("sha256sum") + .arg(&installer_path) + .status(); + } + + let status = std::process::Command::new("sh") + .arg(&installer_path) + .status()?; + + // NamedTempFile::drop removes the file; explicit drop to sequence it + // after sh exits. + drop(tmp); + + if !status.success() { + eprintln!("\nUpdate failed. You can also update manually:"); + eprintln!(" cargo install abtop --force"); + std::process::exit(1); + } + + Ok(()) +} + +fn fmt_tok(n: u64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}k", n as f64 / 1_000.0) + } else { + format!("{}", n) + } +} diff --git a/src/main.rs b/src/main.rs index ae975e6..c08bb08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,468 +1,6 @@ -mod app; -mod collector; -mod config; -mod demo; -mod host_info; -mod locale; -mod model; -mod setup; -mod theme; -mod ui; +//! Binary entry point. All logic lives in the `abtop` library crate so it can +//! be reused in-process by other tools (see `src/lib.rs` and `src/snapshot.rs`). -use app::{App, JumpOutcome}; -use crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton, - MouseEvent, MouseEventKind, -}; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; -use crossterm::ExecutableCommand; -use ratatui::prelude::*; -use std::io::{self, stdout}; -use std::time::Duration; - -fn main() -> io::Result<()> { - // --version / -V flag: print version and exit - if std::env::args().any(|a| a == "--version" || a == "-V") { - println!("abtop {}", env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // --update flag: self-update via GitHub releases installer - if std::env::args().any(|a| a == "--update") { - return run_update(); - } - - // --setup flag: configure StatusLine hook and exit - if std::env::args().any(|a| a == "--setup") { - setup::run_setup(); - return Ok(()); - } - - // Load config once; it drives both the default theme and the hidden-agents list. - let cfg = config::load_config(); - - // --theme flag > config file > default - let initial_theme = std::env::args() - .position(|a| a == "--theme") - .map(|pos| { - let val = std::env::args().nth(pos + 1); - match val { - Some(name) if !name.starts_with('-') => name, - Some(name) => { - eprintln!("--theme requires a theme name, got '{}'", name); - eprintln!("available: {}", theme::THEME_NAMES.join(", ")); - std::process::exit(1); - } - None => { - eprintln!("--theme requires a theme name"); - eprintln!("available: {}", theme::THEME_NAMES.join(", ")); - std::process::exit(1); - } - } - }) - .map(|name| { - theme::Theme::by_name(&name).unwrap_or_else(|| { - eprintln!( - "unknown theme '{}'. available: {}", - name, - theme::THEME_NAMES.join(", ") - ); - std::process::exit(1); - }) - }) - .or_else(|| theme::Theme::by_name(&cfg.theme)); - - let demo_mode = std::env::args().any(|a| a == "--demo"); - let exit_on_jump = std::env::args().any(|a| a == "--exit-on-jump"); - - // --once flag: print snapshot and exit - if std::env::args().any(|a| a == "--once") { - let mut app = App::new_with_config_and_claude_dirs( - initial_theme.unwrap_or_default(), - &cfg.hidden_agents, - cfg.panels, - &cfg.claude_config_dirs, - ); - if demo_mode { - demo::populate_demo(&mut app); - } else { - app.tick(); - // Wait for summaries: retry-aware budget (up to 30s total to allow 2 × 10s attempts + slack) - let deadline = std::time::Instant::now() + Duration::from_secs(30); - while std::time::Instant::now() < deadline { - app.drain_and_retry_summaries(); - if !app.has_pending_summaries() && !app.has_retryable_summaries() { - break; - } - std::thread::sleep(Duration::from_millis(500)); - } - } - print_snapshot(&app); - return Ok(()); - } - - // Setup terminal - enable_raw_mode()?; - stdout().execute(EnterAlternateScreen)?; - stdout().execute(EnableMouseCapture)?; - let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - - let app_result = run_app( - &mut terminal, - demo_mode, - initial_theme, - exit_on_jump, - &cfg.hidden_agents, - cfg.panels, - &cfg.claude_config_dirs, - ); - - // Always attempt both cleanup steps regardless of app result - let r1 = stdout().execute(DisableMouseCapture).map(|_| ()); - let r2 = disable_raw_mode(); - let r3 = stdout().execute(LeaveAlternateScreen).map(|_| ()); - - // Return app error first, then cleanup errors - app_result.and(r1).and(r2).and(r3) -} - -fn run_app( - terminal: &mut Terminal>, - demo_mode: bool, - initial_theme: Option, - exit_on_jump: bool, - hidden_agents: &[String], - panels: config::PanelVisibility, - claude_config_dirs: &[std::path::PathBuf], -) -> io::Result<()> { - let mut app = App::new_with_config_and_claude_dirs( - initial_theme.unwrap_or_default(), - hidden_agents, - panels, - claude_config_dirs, - ); - if demo_mode { - demo::populate_demo(&mut app); - } else { - app.tick(); - } - - let mut last_tick = std::time::Instant::now(); - let tick_interval = Duration::from_secs(2); - let render_interval = Duration::from_millis(500); - - loop { - terminal.draw(|f| ui::draw(f, &app))?; - - // Poll at 500ms for smooth animations; data tick every 2s - let had_input = if event::poll(render_interval)? { - match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { - if app.help_open { - // Any key dismisses help. - app.help_open = false; - } else if app.view_open { - match key.code { - KeyCode::Esc | KeyCode::Char('v') => app.view_open = false, - KeyCode::Char('T') => app.tree_view = !app.tree_view, - KeyCode::Char('l') => app.toggle_timeline(), - KeyCode::Char('f') => app.toggle_file_audit(), - KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), - KeyCode::Char('M') => app.toggle_mcp_session_suppression(), - KeyCode::Char('t') => app.cycle_theme(), - _ => {} - } - } else if app.config_open { - match key.code { - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('c') => { - app.toggle_config() - } - KeyCode::Down | KeyCode::Char('j') => app.config_select_next(), - KeyCode::Up | KeyCode::Char('k') => app.config_select_prev(), - KeyCode::Enter | KeyCode::Char(' ') => app.config_toggle_selected(), - _ => {} - } - } else if app.filter_active { - match key.code { - KeyCode::Esc => app.clear_filter(), - KeyCode::Enter => app.filter_active = false, - KeyCode::Backspace => app.filter_pop(), - KeyCode::Down => app.select_next(), - KeyCode::Up => app.select_prev(), - KeyCode::Char(c) => app.filter_push(c), - _ => {} - } - } else { - match key.code { - KeyCode::Char('q') => app.quit(), - KeyCode::Char('r') if !demo_mode => app.tick(), - KeyCode::Down | KeyCode::Char('j') => app.select_next(), - KeyCode::Up | KeyCode::Char('k') => app.select_prev(), - KeyCode::Right | KeyCode::Tab => app.select_next_narrow_tab(), - KeyCode::Left | KeyCode::BackTab => app.select_prev_narrow_tab(), - KeyCode::Char('w') => app.set_narrow_tab(app::NarrowTab::Work), - KeyCode::Char('u') => app.set_narrow_tab(app::NarrowTab::Usage), - KeyCode::Char('s') => app.set_narrow_tab(app::NarrowTab::System), - KeyCode::Char('+') | KeyCode::Char('=') => { - app.maximize_active_narrow_section() - } - KeyCode::Char('-') => app.restore_narrow_sections(), - KeyCode::Char('x') if !demo_mode => app.kill_selected(), - KeyCode::Char('X') if !demo_mode => app.kill_orphan_ports(), - KeyCode::Char('t') => app.cycle_theme(), - KeyCode::Char('T') => app.tree_view = !app.tree_view, - KeyCode::Char('l') | KeyCode::Char('L') => app.toggle_timeline(), - KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), - KeyCode::Char('M') => app.toggle_mcp_session_suppression(), - KeyCode::Char('c') => app.toggle_config(), - KeyCode::Char('v') => app.toggle_view_menu(), - KeyCode::Char('?') => app.toggle_help(), - KeyCode::Char('/') => app.filter_active = true, - KeyCode::Esc if !app.filter_text.is_empty() => app.clear_filter(), - KeyCode::Char('f') | KeyCode::Char('F') => app.toggle_file_audit(), - KeyCode::Enter if !demo_mode => match app.jump_to_session() { - JumpOutcome::Jumped if exit_on_jump => app.quit(), - JumpOutcome::Failed(msg) => app.set_status(msg), - JumpOutcome::Jumped | JumpOutcome::NoOp => {} - }, - _ => {} - } - } - } - Event::Mouse(mouse) => { - let size = terminal.size()?; - let area = Rect::new(0, 0, size.width, size.height); - handle_mouse_event(&mut app, mouse, area); - } - _ => {} - } - true - } else { - false - }; - - if demo_mode { - // Rotate token rates to animate the sparkline - if let Some(front) = app.token_rates.pop_front() { - app.token_rates.push_back(front); - } - } else if !had_input && last_tick.elapsed() >= tick_interval { - // Data tick every 2s — skip when handling input to avoid lag - app.tick(); - last_tick = std::time::Instant::now(); - } - - if app.should_quit { - break; - } - } - - Ok(()) -} - -fn handle_mouse_event(app: &mut App, mouse: MouseEvent, area: Rect) { - if app.help_open || app.view_open || app.config_open || app.filter_active { - return; - } - - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - if let Some(target) = ui::click_target(app, area, mouse.column, mouse.row) { - match target { - ui::ClickTarget::NarrowTab(tab) => app.set_narrow_tab(tab), - ui::ClickTarget::NarrowSection(section) => { - app.set_active_narrow_section(section); - } - ui::ClickTarget::NarrowZoom(section) => { - app.toggle_narrow_section_zoom(section); - } - ui::ClickTarget::Session(index) => { - app.select_session(index); - app.set_active_narrow_section(app::NarrowSection::Sessions); - } - ui::ClickTarget::KillOrphanPorts => { - app.set_active_narrow_section(app::NarrowSection::Ports); - app.kill_orphan_ports(); - } - } - } - } - MouseEventKind::ScrollDown => app.select_next(), - MouseEventKind::ScrollUp => app.select_prev(), - MouseEventKind::ScrollRight => app.select_next_narrow_tab(), - MouseEventKind::ScrollLeft => app.select_prev_narrow_tab(), - _ => {} - } -} - -/// Strip control characters (including ANSI escapes) and Unicode bidi -/// overrides from a string for safe terminal output. Defeats CVE-2021-42574 -/// (Trojan Source) style attacks via RTLO/LRO/PDF/isolate characters. -fn sanitize_output(s: &str) -> String { - s.chars() - .filter(|c| { - !c.is_control() - && !matches!(*c, - '\u{202A}'..='\u{202E}' - | '\u{2066}'..='\u{2069}' - | '\u{200E}' - | '\u{200F}') - }) - .collect() -} - -fn print_snapshot(app: &App) { - println!( - "abtop — {} sessions, {} mcp servers\n", - app.sessions.len(), - app.mcp_servers.len() - ); - if !app.mcp_servers.is_empty() { - let now = std::time::SystemTime::now(); - for server in &app.mcp_servers { - let active = server.active_count(now, collector::mcp::ACTIVE_MTIME_SECS); - let total = server.rollouts.len(); - let last_age = server - .latest_mtime() - .and_then(|m| now.duration_since(m).ok()) - .map(|d| { - if d.as_secs() < 60 { - format!("{}s", d.as_secs()) - } else if d.as_secs() < 3600 { - format!("{}m", d.as_secs() / 60) - } else { - format!("{}h", d.as_secs() / 3600) - } - }) - .unwrap_or_else(|| "—".to_string()); - let profile = server.profile.as_deref().unwrap_or("default"); - println!( - " mcp pid={} parent={} profile={:<16} active={}/{} last={}", - server.pid, server.parent_cli, profile, active, total, last_age - ); - } - println!(); - } - for session in &app.sessions { - let status = match &session.status { - model::SessionStatus::Thinking => "◉ Think", - model::SessionStatus::Executing => "● Exec", - model::SessionStatus::Waiting => "◌ Wait", - model::SessionStatus::Unknown => "? Unknown", - model::SessionStatus::RateLimited => "⏳ Rate", - model::SessionStatus::Done => "✓ Done", - }; - let sid_short = if session.session_id.len() >= 7 { - &session.session_id[..7] - } else { - &session.session_id - }; - let project_label = format!("{}({})", session.project_name, sid_short); - let summary = sanitize_output(&app.session_summary(session)); - println!( - " {} {:<20} {} {} {:<10} CTX:{:>3.0}% Tok:{} Mem:{}M {}", - session.pid, - sanitize_output(&project_label), - summary, - status, - session.model.replace("claude-", ""), - session.context_percent, - fmt_tok(session.total_tokens()), - session.mem_mb, - session.elapsed_display(), - ); - if let Some(task) = session.current_tasks.last() { - println!(" └─ {}", sanitize_output(task)); - } - for child in &session.children { - let port = child.port.map(|p| format!(":{}", p)).unwrap_or_default(); - println!( - " {} {} {}K {}", - child.pid, - sanitize_output( - &child - .command - .split_whitespace() - .take(3) - .collect::>() - .join(" ") - ), - child.mem_kb / 1024, - port, - ); - } - } -} - -fn run_update() -> io::Result<()> { - let current = env!("CARGO_PKG_VERSION"); - println!("abtop v{current} — checking for updates...\n"); - - // Download to a private temp file (O_EXCL + random suffix) so a local - // attacker can't pre-place a symlink or swap the file mid-run. - let tmp = tempfile::Builder::new() - .prefix("abtop-installer-") - .suffix(".sh") - .tempfile()?; - let installer_path = tmp.path().to_path_buf(); - - let dl_status = std::process::Command::new("curl") - .args([ - "--proto", - "=https", - "--tlsv1.2", - "-LsSf", - "https://github.com/graykode/abtop/releases/latest/download/abtop-installer.sh", - "-o", - ]) - .arg(&installer_path) - .status()?; - - if !dl_status.success() { - eprintln!("\nDownload failed. You can also update manually:"); - eprintln!(" cargo install abtop --force"); - std::process::exit(1); - } - - // Show checksum so the user can verify if desired. - // macOS ships `shasum` (Perl) by default, Linux ships `sha256sum` (coreutils). - let checksum_shown = std::process::Command::new("shasum") - .args(["-a", "256"]) - .arg(&installer_path) - .status() - .map(|s| s.success()) - .unwrap_or(false); - if !checksum_shown { - let _ = std::process::Command::new("sha256sum") - .arg(&installer_path) - .status(); - } - - let status = std::process::Command::new("sh") - .arg(&installer_path) - .status()?; - - // NamedTempFile::drop removes the file; explicit drop to sequence it - // after sh exits. - drop(tmp); - - if !status.success() { - eprintln!("\nUpdate failed. You can also update manually:"); - eprintln!(" cargo install abtop --force"); - std::process::exit(1); - } - - Ok(()) -} - -fn fmt_tok(n: u64) -> String { - if n >= 1_000_000 { - format!("{:.1}M", n as f64 / 1_000_000.0) - } else if n >= 1_000 { - format!("{:.1}k", n as f64 / 1_000.0) - } else { - format!("{}", n) - } +fn main() -> std::io::Result<()> { + abtop::run() } diff --git a/src/model/session.rs b/src/model/session.rs index b6e2211..2bc5f24 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::fmt; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -33,7 +33,7 @@ pub struct FileAccess { pub const MAX_FILE_ACCESSES: usize = 1000; /// Account-level rate limit info (shared across all sessions). -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize)] pub struct RateLimitInfo { /// "claude" or "codex" pub source: String, @@ -49,7 +49,7 @@ pub struct RateLimitInfo { pub updated_at: Option, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub enum SessionStatus { /// Model is generating a response (last_user_ts_ms > 0) Thinking, @@ -72,7 +72,7 @@ impl SessionStatus { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct ChildProcess { pub pid: u32, pub command: String, @@ -81,7 +81,7 @@ pub struct ChildProcess { } /// A port left open by a process whose parent session has ended. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct OrphanPort { pub port: u16, pub pid: u32, diff --git a/src/snapshot.rs b/src/snapshot.rs new file mode 100644 index 0000000..3ce950d --- /dev/null +++ b/src/snapshot.rs @@ -0,0 +1,392 @@ +//! Serializable snapshot of live monitor state for the JSON / Web API. +//! +//! Builds an owned, JSON-friendly view from an [`App`] so headless consumers +//! (e.g. a web server) can serialize the same data the TUI renders without +//! depending on ratatui. The list fields stay lean; a bounded tail of the +//! richer per-session fields (token history, recent tool calls, chat tail, +//! subagents) is also included for the detail view. The unbounded file-access +//! audit and full transcripts are still omitted to keep the payload small. +//! +//! This is a pure read: [`App::to_snapshot`] never ticks or spawns anything. +//! Call it after [`App::tick_no_summaries`] (or `tick`) on a background thread. + +use crate::app::App; +use crate::collector::mcp::ACTIVE_MTIME_SECS; +use crate::host_info::{AgentAggregate, HostMetrics}; +use crate::model::{ + ChatRole, ChildProcess, OrphanPort, RateLimitInfo, SessionStatus, MAX_CHAT_MESSAGES, +}; +use serde::Serialize; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Top-level snapshot returned by [`App::to_snapshot`]. +#[derive(Debug, Clone, Serialize)] +pub struct Snapshot { + /// Unix-epoch milliseconds when this snapshot was built. + pub generated_at_ms: u64, + /// Host vitals (CPU / mem / load1). `None` on non-Linux or before the + /// first valid sample. + pub host: Option, + /// Aggregate metrics across all sessions. + pub aggregate: AgentAggregate, + /// Most recent per-tick token rate: the delta of *active* tokens, where + /// active = input + output + cache_create (cache_read is excluded to avoid + /// inflated rates). It therefore will NOT equal successive `total_tokens` + /// diffs (which include cache_read). `0.0` on the first tick of a fresh + /// process (no prior totals to diff against). + pub token_rate: f64, + /// Collector tick interval in milliseconds. Divide `token_rate` by + /// `interval_ms / 1000` for a per-second rate. + pub interval_ms: u64, + /// Live agent sessions, newest first (same order as the TUI). + pub sessions: Vec, + /// Account-level rate limits (Claude, Codex, …). + pub rate_limits: Vec, + /// Ports left open by processes whose parent session has ended. Empty on a + /// one-shot snapshot — orphan detection needs cross-tick history, so it + /// only populates for a long-running monitor. + pub orphan_ports: Vec, + /// Detected MCP servers (currently `codex mcp-server`). + pub mcp_servers: Vec, +} + +/// One chat line from the transcript tail (detail view only). +#[derive(Debug, Clone, Serialize)] +pub struct ChatMsgView { + /// Speaker: the string `"user"` or `"assistant"` (stable wire values). + pub role: &'static str, + /// Redacted message text (tool inputs/results are excluded upstream). + pub text: String, +} + +/// One tool invocation (detail view only). +#[derive(Debug, Clone, Serialize)] +pub struct ToolCallView { + /// Tool name, e.g. `"Read"`, `"Edit"`, `"Bash"`, `"Grep"`, `"Agent"`. + pub name: String, + /// Short argument preview (file path, command prefix, or pattern). + pub arg: String, + /// Observed duration in milliseconds; `0` when unknown. + pub duration_ms: u64, +} + +/// One spawned subagent (detail view only). +#[derive(Debug, Clone, Serialize)] +pub struct SubAgentView { + /// Subagent name/label. + pub name: String, + /// Free-text status reported for the subagent (e.g. `"working"`, `"done"`). + pub status: String, + /// Tokens attributed to this subagent. + pub tokens: u64, +} + +/// A single session, flattened and curated for JSON consumers. +#[derive(Debug, Clone, Serialize)] +pub struct SessionView { + /// Owning CLI: "claude", "codex", "opencode". + pub agent_cli: &'static str, + /// OS process id of the agent CLI for this session. + pub pid: u32, + /// Agent-assigned session identifier (stable for the life of the session). + pub session_id: String, + /// Project / workspace name (usually the basename of `cwd`). + pub project_name: String, + /// Absolute working directory of the session. + pub cwd: String, + /// Home-abbreviated config root (e.g. "~/.claude", "~/.codex"). + pub config_root: String, + /// Coarse activity state; serializes as its variant name (e.g. `"Thinking"`). + pub status: SessionStatus, + /// Model identifier reported by the session (e.g. `"claude-opus-4-6"`). + pub model: String, + /// Reasoning effort (Codex only); empty when N/A. + pub effort: String, + /// Agent CLI version string, if known. + pub version: String, + /// Context-window fill, 0.0–100.0 percent. + pub context_percent: f64, + /// Total context-window size in tokens (e.g. 200000). + pub context_window: u64, + /// All token classes summed: input + output + cache read + cache write. + pub total_tokens: u64, + /// Cumulative input (prompt) tokens for the session. + pub input_tokens: u64, + /// Cumulative output (completion) tokens for the session. + pub output_tokens: u64, + /// Cumulative cache-read tokens (excluded from the active-token rate). + pub cache_read_tokens: u64, + /// Cumulative cache-write (cache-creation) tokens. + pub cache_create_tokens: u64, + /// Number of user/assistant turns observed. + pub turn_count: u32, + /// Resident memory of the session process tree, in MiB. + pub mem_mb: u64, + /// Current git branch of `cwd`, or empty when not a repo. + pub git_branch: String, + /// Files added in the working tree (git status), not session-scoped. + pub git_added: u32, + /// Files modified in the working tree (git status), not session-scoped. + pub git_modified: u32, + /// Session start, Unix-epoch milliseconds. + pub started_at_ms: u64, + /// Wall-clock seconds since `started_at_ms`. + pub elapsed_secs: u64, + /// Display summary: cached LLM title if present, else a safe raw-prompt + /// fallback. Never triggers summary generation. + pub summary: String, + /// Most recent current-task line, if any. + pub current_task: Option, + /// Child processes, each with any owned listening port. + pub children: Vec, + // --- richer fields for the per-session detail view --- + /// Number of detected context-compaction events. + pub compaction_count: u32, + /// Per-turn token totals for a sparkline (trimmed tail). The absolute scale + /// differs by agent (Claude counts cache tokens, Codex does not), so use it + /// as a relative per-session trend, not for cross-session magnitude. + pub token_history: Vec, + /// Spawned subagents, if any. + pub subagents: Vec, + /// Recent tool-call timeline (trimmed tail, newest last). + pub tool_calls: Vec, + /// Recent chat transcript tail (user/assistant only). + pub chat_messages: Vec, +} + +/// A detected MCP server, with the internal `SystemTime` mtime resolved to a +/// plain epoch-millis number for web clients. +#[derive(Debug, Clone, Serialize)] +pub struct McpServerView { + /// OS process id of the MCP server. + pub pid: u32, + /// Resolved parent CLI: "claude", "codex", or "?". + pub parent_cli: &'static str, + /// `-c profile=` value, if any. + pub profile: Option, + /// Resident memory of the MCP server process, in KiB. + pub mem_kb: u64, + /// Rollouts written within the active-mtime window. + pub active_count: usize, + /// Total open rollout fds. + pub rollout_count: usize, + /// Latest rollout mtime as Unix-epoch milliseconds, if known. + pub last_activity_ms: Option, +} + +/// Keep at most the last `n` items of a slice. +fn tail(v: &[T], n: usize) -> Vec { + if v.len() > n { + v[v.len() - n..].to_vec() + } else { + v.to_vec() + } +} + +fn epoch_ms(t: SystemTime) -> Option { + t.duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_millis() as u64) +} + +impl App { + /// Build an owned, JSON-serializable snapshot of the current monitor state. + /// + /// Pure read — does not tick or spawn anything. Intended flow for a web + /// server: lock the `App`, `tick_no_summaries()`, `to_snapshot()`, release. + pub fn to_snapshot(&self, interval_ms: u64) -> Snapshot { + let now = SystemTime::now(); + + let sessions = self + .sessions + .iter() + .map(|s| SessionView { + agent_cli: s.agent_cli, + pid: s.pid, + session_id: s.session_id.clone(), + project_name: s.project_name.clone(), + cwd: s.cwd.clone(), + config_root: s.config_root.clone(), + status: s.status.clone(), + model: s.model.clone(), + effort: s.effort.clone(), + version: s.version.clone(), + context_percent: s.context_percent, + context_window: s.context_window, + total_tokens: s.total_tokens(), + input_tokens: s.total_input_tokens, + output_tokens: s.total_output_tokens, + cache_read_tokens: s.total_cache_read, + cache_create_tokens: s.total_cache_create, + turn_count: s.turn_count, + mem_mb: s.mem_mb, + git_branch: s.git_branch.clone(), + git_added: s.git_added, + git_modified: s.git_modified, + started_at_ms: s.started_at, + elapsed_secs: s.elapsed().as_secs(), + summary: self.session_summary(s), + current_task: s.current_tasks.last().cloned(), + children: s.children.clone(), + compaction_count: s.compaction_count, + token_history: tail(&s.token_history, 64), + subagents: tail(&s.subagents, 16) + .iter() + .map(|a| SubAgentView { + name: a.name.clone(), + status: a.status.clone(), + tokens: a.tokens, + }) + .collect(), + tool_calls: tail(&s.tool_calls, 24) + .iter() + .map(|t| ToolCallView { + name: t.name.clone(), + arg: t.arg.clone(), + duration_ms: t.duration_ms, + }) + .collect(), + chat_messages: tail(&s.chat_messages, MAX_CHAT_MESSAGES) + .iter() + .map(|m| ChatMsgView { + role: match &m.role { + ChatRole::User => "user", + ChatRole::Assistant => "assistant", + }, + text: m.text.clone(), + }) + .collect(), + }) + .collect(); + + let mcp_servers = self + .mcp_servers + .iter() + .map(|m| McpServerView { + pid: m.pid, + parent_cli: m.parent_cli, + profile: m.profile.clone(), + mem_kb: m.mem_kb, + active_count: m.active_count(now, ACTIVE_MTIME_SECS), + rollout_count: m.rollouts.len(), + last_activity_ms: m.latest_mtime().and_then(epoch_ms), + }) + .collect(); + + Snapshot { + generated_at_ms: epoch_ms(now).unwrap_or(0), + host: self.host_metrics, + aggregate: self.agent_aggregate, + token_rate: self.token_rates.back().copied().unwrap_or(0.0), + interval_ms, + sessions, + rate_limits: self.rate_limits.clone(), + orphan_ports: self.orphan_ports.clone(), + mcp_servers, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use crate::config::PanelVisibility; + use crate::demo::populate_demo; + use crate::model::SessionStatus; + use crate::theme::Theme; + use std::time::{Duration, UNIX_EPOCH}; + + fn demo_app() -> App { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + populate_demo(&mut app); + app + } + + #[test] + fn tail_keeps_last_n_and_handles_short_inputs() { + let v = vec![1, 2, 3, 4, 5]; + assert_eq!(tail(&v, 2), vec![4, 5]); // last n + assert_eq!(tail(&v, 5), vec![1, 2, 3, 4, 5]); // exact fit + assert_eq!(tail(&v, 9), vec![1, 2, 3, 4, 5]); // n > len → full clone + assert_eq!(tail(&v, 0), Vec::::new()); // n = 0 → empty + assert_eq!(tail(&Vec::::new(), 3), Vec::::new()); // empty input + } + + #[test] + fn epoch_ms_is_monotonic_and_zero_at_unix_epoch() { + assert_eq!(epoch_ms(UNIX_EPOCH), Some(0)); + let later = UNIX_EPOCH + Duration::from_millis(1_500); + assert_eq!(epoch_ms(later), Some(1_500)); + } + + #[test] + fn session_status_serializes_as_variant_name() { + // The web UI matches on these exact strings — they are part of the + // stable JSON contract and must not be renamed without a major bump. + for (status, wire) in [ + (SessionStatus::Thinking, "\"Thinking\""), + (SessionStatus::Executing, "\"Executing\""), + (SessionStatus::Waiting, "\"Waiting\""), + (SessionStatus::Unknown, "\"Unknown\""), + (SessionStatus::RateLimited, "\"RateLimited\""), + (SessionStatus::Done, "\"Done\""), + ] { + assert_eq!(serde_json::to_string(&status).unwrap(), wire); + } + } + + #[test] + fn to_snapshot_is_a_pure_read() { + let app = demo_app(); + let before = app.sessions.len(); + let a = app.to_snapshot(2_000); + let b = app.to_snapshot(2_000); + // No mutation of the App, and repeated calls agree on shape. + assert_eq!(app.sessions.len(), before); + assert_eq!(a.sessions.len(), b.sessions.len()); + assert_eq!(a.sessions.len(), before); + } + + #[test] + fn to_snapshot_maps_fields_and_passes_interval_through() { + let app = demo_app(); + let snap = app.to_snapshot(1_234); + + assert_eq!(snap.interval_ms, 1_234); + assert!(snap.generated_at_ms > 0); + assert!(!snap.sessions.is_empty()); + assert!(snap.host.is_some(), "demo populates host metrics"); + assert!(!snap.rate_limits.is_empty(), "demo populates rate limits"); + + for s in &snap.sessions { + // Bounded tails. + assert!(s.token_history.len() <= 64); + assert!(s.tool_calls.len() <= 24); + // Chat roles map to the stable wire strings only. + for m in &s.chat_messages { + assert!(m.role == "user" || m.role == "assistant"); + } + } + } + + #[test] + fn snapshot_round_trips_through_serde_json() { + let snap = demo_app().to_snapshot(2_000); + let json = serde_json::to_string(&snap).expect("snapshot serializes"); + assert!(json.contains("\"sessions\"")); + assert!(json.contains("\"interval_ms\":2000")); + // Re-parse as generic JSON to confirm it is well-formed. + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); + assert!(parsed["sessions"].is_array()); + } + + #[test] + fn readme_documents_json_snapshot_privacy_surface() { + let readme = include_str!("../README.md"); + assert!(readme.contains("--json")); + assert!(readme.contains("JSON snapshot includes")); + assert!(readme.contains("chat_messages")); + assert!(readme.contains("summary")); + } +}