//! Terminal user interface (port of the Glances curses interface). use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; use glances_core::plugin::{Align, Decoration, StatLine}; use glances_core::processes::processes; use glances_core::{Args, RenderContext}; use glances_plugins::engine::Engine; use ratatui::Frame; use ratatui::backend::CrosstermBackend; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; use std::io::Stdout; use std::time::{Duration, Instant}; const LEFT_SIDEBAR: [&str; 11] = [ "network", "ports", "wifi", "diskio", "fs", "irq", "folders", "raid", "sensors", "ip", "now", ]; const TOP_PLUGINS: [&str; 6] = ["cpu", "percpu", "mem", "memswap", "load", "gpu"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Popup { None, Help, Filter, Kill, } pub struct Tui { popup: Popup, input: String, quit: bool, } /// Run the terminal interface until the user quits. pub fn run(engine: &mut Engine) -> Result<()> { enable_raw_mode()?; let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = ratatui::Terminal::new(backend)?; let mut tui = Tui { popup: Popup::None, input: String::new(), quit: false, }; let result = run_loop(&mut terminal, engine, &mut tui); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; result } fn run_loop( terminal: &mut ratatui::Terminal>, engine: &mut Engine, tui: &mut Tui, ) -> Result<()> { let refresh = engine.args.time.unwrap_or(2.0); let mut count: i64 = 0; while !tui.quit { let start = Instant::now(); engine.update(); tui.draw(terminal, engine)?; count += 1; if let Some(max) = engine.args.stop_after && count >= max { break; } let elapsed = start.elapsed().as_secs_f64(); let timeout = Duration::from_secs_f64((refresh - elapsed).max(0.05)); let deadline = Instant::now() + timeout; while Instant::now() < deadline { let remaining = deadline.saturating_duration_since(Instant::now()); if !event::poll(remaining)? { break; } match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => tui.handle_key(engine, key), Event::Resize(_, _) => break, _ => {} } } } Ok(()) } impl Tui { fn draw( &self, terminal: &mut ratatui::Terminal>, engine: &mut Engine, ) -> Result<()> { terminal.draw(|frame| draw_frame(frame, engine, self))?; Ok(()) } } fn draw_frame(frame: &mut Frame, engine: &mut Engine, tui: &Tui) { let args = engine.args.clone(); let args = &args; let area = frame.area(); if area.width < 40 || area.height < 10 { return; } if tui.popup == Popup::Help { tui.draw_help(frame, area); return; } // Header. let header_height = 1u16; let top_height = tui.top_height(engine, args, area.width); let footer_height = if tui.popup == Popup::Filter || tui.popup == Popup::Kill { 3 } else { 0 }; let main_height = area .height .saturating_sub(header_height + top_height + footer_height); let header_area = Rect { x: 0, y: 0, width: area.width, height: header_height, }; let top_area = Rect { x: 0, y: header_height, width: area.width, height: top_height, }; let main_area = Rect { x: 0, y: header_height + top_height, width: area.width, height: main_height, }; let footer_area = Rect { x: 0, y: area.height.saturating_sub(footer_height), width: area.width, height: footer_height, }; tui.draw_header(frame, header_area, engine, args); tui.draw_top(frame, top_area, engine, args); tui.draw_main(frame, main_area, engine, args); if footer_height > 0 { tui.draw_popup(frame, footer_area); } } impl Tui { fn top_height(&self, engine: &mut Engine, args: &Args, width: u16) -> u16 { if args.disable_top { return 1; } let mut height = 1usize; for name in TOP_PLUGINS { if let Some(plugin) = engine.plugins.get_mut(name) { if !plugin.is_enabled() { continue; } let ctx = render_ctx(args, &engine.runtime, width as usize); let lines = split_lines(plugin.render(&ctx)); height = height.max(lines.len()); } } // Quicklook. if let Some(plugin) = engine.plugins.get_mut("quicklook") && plugin.is_enabled() && !args.disable_quicklook { let ql_width = quicklook_width(width); let ctx = render_ctx(args, &engine.runtime, ql_width as usize); let lines = split_lines(plugin.render(&ctx)); height = height.max(lines.len()); } (height.min(20) as u16).max(1) } fn draw_header(&self, frame: &mut Frame, area: Rect, engine: &mut Engine, args: &Args) { let x = area.x; // System information. if let Some(plugin) = engine.plugins.get_mut("system") && plugin.is_enabled() { let ctx = render_ctx(args, &engine.runtime, area.width as usize); let lines = split_lines(plugin.render(&ctx)); let text: String = lines .first() .map(|l| l.iter().map(|s| s.msg.clone()).collect()) .unwrap_or_default(); let style = lines .first() .and_then(|l| l.first()) .map(|s| style_for(s.decoration, args)) .unwrap_or_default(); let width = text.chars().count() as u16; frame.render_widget( Paragraph::new(text).style(style), Rect { x, y: area.y, width: width.min(area.width), height: 1, }, ); } // Uptime on the right. if let Some(plugin) = engine.plugins.get_mut("uptime") && plugin.is_enabled() { let ctx = render_ctx(args, &engine.runtime, area.width as usize); let lines = split_lines(plugin.render(&ctx)); let text: String = lines .first() .map(|l| l.iter().map(|s| s.msg.clone()).collect()) .unwrap_or_default(); let width = text.chars().count() as u16; if width + 2 < area.width { frame.render_widget( Paragraph::new(text), Rect { x: area.width.saturating_sub(width), y: area.y, width, height: 1, }, ); } } } fn draw_top(&self, frame: &mut Frame, area: Rect, engine: &mut Engine, args: &Args) { let mut x = area.x; // Collect the rendered blocks with their natural widths. struct Block { lines: Vec>, natural_width: usize, } let mut blocks: Vec = Vec::new(); if !args.disable_quicklook && let Some(plugin) = engine.plugins.get_mut("quicklook") && plugin.is_enabled() { let width = quicklook_width(area.width) as usize; let ctx = render_ctx(args, &engine.runtime, width); let lines = split_lines(plugin.render(&ctx)); let natural = lines .iter() .map(|l| l.iter().map(|s| s.msg.chars().count()).sum::()) .max() .unwrap_or(10); blocks.push(Block { lines, natural_width: natural.max(10), }); } for name in TOP_PLUGINS { if let Some(plugin) = engine.plugins.get_mut(name) { if !plugin.is_enabled() { continue; } let ctx = render_ctx(args, &engine.runtime, area.width as usize); let lines = split_lines(plugin.render(&ctx)); let natural = lines .iter() .map(|l| l.iter().map(|s| s.msg.chars().count()).sum::()) .max() .unwrap_or(10); blocks.push(Block { lines, natural_width: natural.max(10), }); } } if blocks.is_empty() { return; } // Allocate the available width proportionally to the natural widths. let total_natural: usize = blocks.iter().map(|b| b.natural_width).sum(); let spacing = 2usize * blocks.len().saturating_sub(1); let available = (area.width as usize) .saturating_sub(spacing) .max(blocks.len() * 8); let mut widths: Vec = Vec::new(); for block in &blocks { let mut width = (available * block.natural_width / total_natural).max(8); width = width.min(block.natural_width + 2); widths.push(width as u16); } // Adjust the last block if the total exceeds the screen. let total: usize = widths.iter().map(|w| *w as usize).sum::() + spacing; if total > area.width as usize { let excess = total - area.width as usize; if let Some(last) = widths.last_mut() { *last = last.saturating_sub(excess as u16).max(8); } } for (block, width) in blocks.into_iter().zip(widths) { if x + width > area.x + area.width { break; } draw_lines( frame, Rect { x, y: area.y, width, height: area.height, }, &block.lines, args, ); x += width + 2; } } fn draw_main(&self, frame: &mut Frame, area: Rect, engine: &mut Engine, args: &Args) { if area.height == 0 { return; } let left_width = if args.disable_left_sidebar { 0 } else { (area.width as i32 - 105).clamp(23, 34) as u16 }; if left_width > 0 && area.width > left_width + 20 { let mut y = area.y; for name in LEFT_SIDEBAR { if y >= area.y + area.height { break; } let Some(plugin) = engine.plugins.get_mut(name) else { continue; }; if !plugin.is_enabled() { continue; } let ctx = render_ctx(args, &engine.runtime, left_width as usize); let lines = split_lines(plugin.render(&ctx)); if lines.is_empty() { continue; } let height = (lines.len() as u16).min(area.y + area.height - y); y += 1; // separator/blank line if y >= area.y + area.height { break; } let height = height.min(area.y + area.height - y); draw_lines( frame, Rect { x: area.x, y, width: left_width, height, }, &lines, args, ); y += height; } } // Right side: process count + processes + alert footer. let right_x = area.x + left_width + if left_width > 0 { 1 } else { 0 }; let right_width = area.width.saturating_sub(right_x - area.x); if right_width < 10 { return; } let mut y = area.y; // Process count. if let Some(plugin) = engine.plugins.get_mut("processcount") && plugin.is_enabled() { let ctx = render_ctx(args, &engine.runtime, right_width as usize); let lines = split_lines(plugin.render(&ctx)); let height = (lines.len() as u16).min(2); draw_lines( frame, Rect { x: right_x, y, width: right_width, height, }, &lines, args, ); y += height; } // Alerts footer (up to 4 lines). let event_lines = self.event_lines(engine, right_width as usize); let alert_height = event_lines.len().min(5) as u16; let list_height = area.y + area.height - y; let process_height = list_height.saturating_sub(alert_height); let list_name = if args.programs { "programlist" } else { "processlist" }; if let Some(plugin) = engine.plugins.get_mut(list_name) && plugin.is_enabled() && process_height > 1 { let ctx = render_ctx(args, &engine.runtime, right_width as usize); let mut lines = split_lines(plugin.render(&ctx)); lines.truncate(process_height as usize); draw_lines( frame, Rect { x: right_x, y, width: right_width, height: process_height, }, &lines, args, ); } y += process_height; if alert_height > 0 { draw_lines( frame, Rect { x: right_x, y, width: right_width, height: alert_height, }, &event_lines, args, ); } } fn event_lines(&self, engine: &Engine, _width: usize) -> Vec> { let events = glances_core::events::events().lock().unwrap(); let mut lines = Vec::new(); if events.is_empty() { return lines; } lines.push(vec![ StatLine::new("EVENTS HISTORY").decoration(Decoration::Title), ]); for event in events.events.iter().rev().take(4) { let state = match event.state.as_str() { "CRITICAL" => Decoration::CriticalLog, "WARNING" => Decoration::WarningLog, _ => Decoration::Default, }; let end = if event.is_ongoing() { String::new() } else { " (ended)".to_string() }; lines.push(vec![ StatLine::new(format!("{} ", event.state)).decoration(state), StatLine::new(format!("{} (avg {:.1}){}", event.r#type, event.avg, end)), ]); } let _ = engine; lines } fn draw_popup(&self, frame: &mut Frame, area: Rect) { let (title, prompt) = match self.popup { Popup::Filter => ("Filter", format!("Process filter: {}_", self.input)), Popup::Kill => ("Kill process", "Confirm ([y]es/[n]o): ".to_string()), _ => return, }; let block = Block::default().borders(Borders::ALL).title(title); frame.render_widget(Clear, area); frame.render_widget(Paragraph::new(prompt).block(block), area); } fn draw_help(&self, frame: &mut Frame, area: Rect) { let lines = vec![ Line::from("Glances-rs help"), Line::from(""), Line::from("q / ESC Quit"), Line::from("h Toggle this help"), Line::from("0 Irix mode (disable-irix)"), Line::from("1 Per CPU mode"), Line::from("2 Disable left sidebar"), Line::from("3 Disable quicklook"), Line::from("4 Full quicklook"), Line::from("5 Disable top menu"), Line::from("6 Mean GPU mode"), Line::from( "a c m i o p t u Sort processes (auto/cpu/mem/io/cpu_time/name/time/user)", ), Line::from(" Cycle sort key"), Line::from("UP/DOWN Select process"), Line::from("+ / - Increase/decrease nice level"), Line::from("k Kill selected process"), Line::from("f / E Edit/erase process filter"), Line::from("z Disable/enable processes"), Line::from("e Extended stats for the selected process"), Line::from("w / x Clean warning/critical events"), Line::from("b Byte mode (network)"), Line::from("T / U Network sum / cumulative"), Line::from("B / L Disk IOPS / latency"), Line::from("F FS free space"), Line::from("S Sparkline"), Line::from("M Reset min/max"), Line::from("d n s l D C I V W P K Toggle plugins"), ]; let block = Block::default().borders(Borders::ALL).title("Help"); frame.render_widget(Clear, area); frame.render_widget( Paragraph::new(lines) .block(block) .wrap(Wrap { trim: false }), area, ); } fn handle_key(&mut self, engine: &mut Engine, key: KeyEvent) { let args = &mut engine.args; // Popup handling first. match self.popup { Popup::Help => { self.popup = Popup::None; return; } Popup::Filter => match key.code { KeyCode::Enter => { let filter = self.input.clone(); let mut procs = processes().lock().unwrap(); procs.filter.set_filter(if filter.is_empty() { None } else { Some(&filter) }); self.popup = Popup::None; } KeyCode::Esc => self.popup = Popup::None, KeyCode::Backspace => { self.input.pop(); } KeyCode::Char(c) => self.input.push(c), _ => {} }, Popup::Kill => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { let cursor = engine.runtime.cursor_position.max(0) as usize; let pid = processes() .lock() .unwrap() .get_list(false) .get(cursor) .and_then(|p| p.get("pid")) .and_then(serde_json::Value::as_i64); if let Some(pid) = pid { processes().lock().unwrap().kill(pid); } self.popup = Popup::None; } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => self.popup = Popup::None, _ => {} }, Popup::None => {} } if self.popup != Popup::None { return; } match key.code { KeyCode::Char('q') | KeyCode::Esc => self.quit = true, KeyCode::Char('h') => self.popup = Popup::Help, KeyCode::Char('0') => engine.runtime.disable_irix = !engine.runtime.disable_irix, KeyCode::Char('1') => { args.percpu = !args.percpu; engine.runtime.percpu = args.percpu; } KeyCode::Char('2') => args.disable_left_sidebar = !args.disable_left_sidebar, KeyCode::Char('3') => args.disable_quicklook = !args.disable_quicklook, KeyCode::Char('4') => args.full_quicklook = !args.full_quicklook, KeyCode::Char('5') => args.disable_top = !args.disable_top, KeyCode::Char('6') => { args.meangpu = !args.meangpu; engine.runtime.meangpu = args.meangpu; } KeyCode::Char('/') => { engine.runtime.process_short_name = !engine.runtime.process_short_name } KeyCode::Char('a') => set_sort(engine, "auto"), KeyCode::Char('c') => set_sort(engine, "cpu_percent"), KeyCode::Char('m') => set_sort(engine, "memory_percent"), KeyCode::Char('i') => set_sort(engine, "io_counters"), KeyCode::Char('o') => set_sort(engine, "cpu_num"), KeyCode::Char('p') => set_sort(engine, "name"), KeyCode::Char('t') => set_sort(engine, "cpu_times"), KeyCode::Char('u') => set_sort(engine, "username"), KeyCode::Char('<') => cycle_sort(engine, false), KeyCode::Char('>') => cycle_sort(engine, true), KeyCode::Up => move_cursor(engine, -1), KeyCode::Down => move_cursor(engine, 1), KeyCode::Char('+') => nice_change(engine, true), KeyCode::Char('-') => nice_change(engine, false), KeyCode::Char('k') => self.popup = Popup::Kill, KeyCode::Char('f') => { self.input = processes() .lock() .unwrap() .filter .filter_input() .unwrap_or("") .to_string(); self.popup = Popup::Filter; } KeyCode::Char('E') => processes().lock().unwrap().filter.set_filter(None), KeyCode::Char('z') => toggle_processes(engine), KeyCode::Char('e') => toggle_extended(engine), KeyCode::Char('w') => { glances_core::events::events().lock().unwrap().clean(false); } KeyCode::Char('x') => { glances_core::events::events().lock().unwrap().clean(true); } KeyCode::Char('b') => args.byte = !args.byte, KeyCode::Char('T') => engine.runtime.network_sum = !engine.runtime.network_sum, KeyCode::Char('U') => engine.runtime.network_cumul = !engine.runtime.network_cumul, KeyCode::Char('B') => args.diskio_iops = !args.diskio_iops, KeyCode::Char('L') => args.diskio_latency = !args.diskio_latency, KeyCode::Char('F') => engine.runtime.fs_free_space = !engine.runtime.fs_free_space, KeyCode::Char('S') => engine.runtime.sparkline = !engine.runtime.sparkline, KeyCode::Char('M') => { engine.runtime.reset_minmax_tag = !engine.runtime.reset_minmax_tag } KeyCode::Char('j') => { args.programs = !args.programs; engine.runtime.programs = args.programs; } KeyCode::Char( c @ ('d' | 'n' | 's' | 'l' | 'D' | 'C' | 'I' | 'V' | 'W' | 'P' | 'K' | 'R'), ) => { toggle_plugin_by_key(engine, c); } _ => {} } } } fn render_ctx<'a>( args: &'a Args, runtime: &'a glances_core::Runtime, max_width: usize, ) -> RenderContext<'a> { RenderContext { args, runtime, max_width, } } fn quicklook_width(width: u16) -> u16 { ((width as f64 * 0.28) as u16).clamp(24, 48) } fn split_lines(lines: Vec) -> Vec> { let mut result: Vec> = vec![Vec::new()]; for line in lines { if line.msg == "\n" { result.push(Vec::new()); } else { result.last_mut().unwrap().push(line); } } result } /// Draw a list of lines into a rect (a line group is a row). fn draw_lines(frame: &mut Frame, area: Rect, lines: &[Vec], args: &Args) { if area.width == 0 || area.height == 0 { return; } let buffer = frame.buffer_mut(); for (row, line) in lines.iter().take(area.height as usize).enumerate() { let y = area.y + row as u16; let mut x = area.x; let mut decoration = Decoration::Default; for segment in line { if segment.decoration != Decoration::Default { decoration = segment.decoration; } let style = style_for(decoration, args); for c in segment.msg.chars() { if x >= area.x + area.width { break; } if let Some(cell) = buffer.cell_mut((x, y)) { cell.set_char(c); cell.set_style(style); } x += 1; } if x >= area.x + area.width { break; } } } } fn style_for(decoration: Decoration, args: &Args) -> Style { let bold = if args.disable_bold { Modifier::empty() } else { Modifier::BOLD }; let bg = args.disable_bg; match decoration { Decoration::Default | Decoration::Password => Style::default(), Decoration::Underline => Style::default().add_modifier(Modifier::UNDERLINED), Decoration::Bold | Decoration::Selected | Decoration::Sort | Decoration::Filter => { Style::default().add_modifier(bold | Modifier::UNDERLINED) } Decoration::Title => Style::default().add_modifier(bold), Decoration::Ok | Decoration::Process | Decoration::Status => { Style::default().fg(Color::Green) } Decoration::Max => Style::default().fg(Color::Green).add_modifier(bold), Decoration::Careful | Decoration::Info => Style::default().fg(Color::Blue), Decoration::CarefulLog => { if bg { Style::default().fg(Color::Blue).add_modifier(bold) } else { Style::default().bg(Color::Blue).add_modifier(bold) } } Decoration::Warning => Style::default().fg(Color::Magenta).add_modifier(bold), Decoration::WarningLog => { if bg { Style::default().fg(Color::Magenta).add_modifier(bold) } else { Style::default().bg(Color::Magenta).add_modifier(bold) } } Decoration::Critical => Style::default().fg(Color::Red).add_modifier(bold), Decoration::CriticalLog => { if bg { Style::default().fg(Color::Red).add_modifier(bold) } else { Style::default().bg(Color::Red).add_modifier(bold) } } Decoration::OkLog => { if bg { Style::default().fg(Color::Green).add_modifier(bold) } else { Style::default().bg(Color::Green).add_modifier(bold) } } Decoration::CpuTime => Style::default().fg(Color::Magenta), Decoration::ProcessSelected => Style::default() .fg(Color::Green) .add_modifier(bold | Modifier::UNDERLINED), Decoration::Error => Style::default().fg(Color::Red), Decoration::Separator => Style::default().fg(Color::DarkGray), } } fn set_sort(_engine: &mut Engine, key: &str) { let mut procs = processes().lock().unwrap(); procs.set_sort_key(key, key != "auto"); procs.auto_sort = key == "auto"; } fn cycle_sort(engine: &mut Engine, forward: bool) { const KEYS: [&str; 7] = [ "cpu_percent", "memory_percent", "username", "cpu_times", "io_counters", "name", "cpu_num", ]; let current = processes().lock().unwrap().sort_key.clone(); let index = KEYS.iter().position(|k| *k == current).unwrap_or(0); let next = if forward { (index + 1) % KEYS.len() } else { (index + KEYS.len() - 1) % KEYS.len() }; set_sort(engine, KEYS[next]); } fn move_cursor(engine: &mut Engine, delta: i64) { let len = processes().lock().unwrap().get_list(false).len() as i64; let max = (len - 1).max(0); engine.runtime.cursor_position = (engine.runtime.cursor_position + delta).clamp(0, max); } fn nice_change(engine: &mut Engine, increase: bool) { let cursor = engine.runtime.cursor_position.max(0) as usize; let pid = processes() .lock() .unwrap() .get_list(false) .get(cursor) .and_then(|p| p.get("pid")) .and_then(serde_json::Value::as_i64); if let Some(pid) = pid { let procs = processes().lock().unwrap(); if increase { procs.nice_increase(pid); } else { procs.nice_decrease(pid); } } } fn toggle_processes(engine: &mut Engine) { let enabled = engine .plugins .get("processlist") .map(|p| p.is_enabled()) .unwrap_or(false); for name in ["processcount", "processlist", "programlist"] { if let Some(plugin) = engine.plugins.get_mut(name) { plugin.set_enabled(!enabled); } } engine.sync_enabled(); } fn toggle_extended(engine: &mut Engine) { let procs = processes().lock().unwrap(); if procs.disable_extended_tag { drop(procs); processes().lock().unwrap().enable_extended(); engine.args.enable_process_extended = true; } else { drop(procs); processes().lock().unwrap().disable_extended(); engine.args.enable_process_extended = false; } } fn toggle_plugin_by_key(engine: &mut Engine, key: char) { let name = match key { 'd' => "diskio", 'n' => "network", 's' => "sensors", 'l' => "alert", 'D' => "containers", 'C' => "cloud", 'I' => "ip", 'V' => "vms", 'W' => "wifi", 'P' => "ports", 'K' => "connections", 'R' => "raid", _ => return, }; if let Some(plugin) = engine.plugins.get_mut(name) { let enabled = plugin.is_enabled(); plugin.set_enabled(!enabled); engine.sync_enabled(); } } /// Keep the `Align` import alive for future use. #[allow(dead_code)] fn unused(_: Align) {} #[cfg(test)] mod tests { use super::*; use ratatui::backend::TestBackend; fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String { let area = buffer.area(); let mut out = String::new(); for y in 0..area.height { for x in 0..area.width { if let Some(cell) = buffer.cell((x, y)) { out.push_str(cell.symbol()); } } out.push('\n'); } out } #[test] fn test_tui_layout_renders() { use clap::Parser; use glances_core::Config; let mut args = Args::parse_from([ "glances", "--disable-plugin", "all", "--enable-plugin", "cpu,mem,load,processlist,processcount,network,fs,diskio,now,system,uptime,quicklook", ]); args.time = Some(1.0); let config = Config::empty(false); let mut engine = Engine::new(args.clone(), config); engine.update(); engine.update(); let backend = TestBackend::new(140, 40); let mut terminal = ratatui::Terminal::new(backend).unwrap(); let tui = Tui { popup: Popup::None, input: String::new(), quit: false, }; terminal .draw(|frame| draw_frame(frame, &mut engine, &tui)) .unwrap(); let text = buffer_to_string(terminal.backend().buffer()); println!("{text}"); assert!(text.contains("CPU")); assert!(text.contains("MEM")); assert!(text.contains("Command") || text.contains("python") || text.contains("COMMAND")); } }