diff --git a/local/docs/redbear-power-improvement-plan.md b/local/docs/redbear-power-improvement-plan.md index dedfe2509a..586050495c 100644 --- a/local/docs/redbear-power-improvement-plan.md +++ b/local/docs/redbear-power-improvement-plan.md @@ -5282,6 +5282,175 @@ does this compare to the recent past" (IO-RATE shape). --- +## 56. v1.32 Per-PID CPU% and RSS Sparklines (2026-06-21) + +Shipped two new per-PID sparklines alongside the existing IO-RATE +sparkline: + +| Column | Width | Source | Range | +|--------|-------|--------|-------| +| IO-RATE | 12 chars | `app.io_history[pid]` | 12 × ~6.5s = ~78s | +| CPU% | 6 chars | `app.cpu_history[pid]` | 6 × 6.5s = ~39s | +| RSS | 6 chars | `app.rss_history[pid]` | 6 × 6.5s = ~39s | + +Storage is `VecDeque` end-to-end (one byte per sample after +normalize). Two-phase normalize: compute f64 pending ratio against +per-key max, then commit `as u8` to the ring buffer. Render via +`sparkline_short()` helper. + +## 57. v1.33 RChar/WChar Sort (VFS-level IO) (2026-06-21) + +Added `SortMode::RChar` and `SortMode::WChar` to the sort cycle. +These sort by VFS-level byte count (`/proc/[pid]/io:rchar`/`wchar`) +which includes cache hits and tty I/O — the right "who is doing the +most file system chatter" question, distinct from "who is doing the +most disk I/O" (the existing IO/RATE columns, which use +`read_bytes`/`write_bytes`). + +RChar/WChar columns swap the `MEM` column header to "RChr" / "WChr" +in the active sort, mirroring how VSize swaps to "VSZ". + +## 58. v1.34 Vertical-Bar Tree Depth Markers (htop-style) (2026-06-21) + +Replaced the simple `└─` leaf marker in `tree_prefix()` with htop- +style vertical bars (`│`) for ancestors. The depth of the tree is +visualized by chaining `│` characters for each level above the +current row. Closed/leaf branches still get `└─` / `├─`. Adds +3 tests to `render::tests`. + +## 59. v1.35 Home/End + g/G Keypresses (2026-06-21) + +Added three new movement primitives to `App`: + +- `move_to_edge(Edge::Top)` — cursor → row 0 +- `move_to_edge(Edge::Bottom)` — cursor → last visible row +- Wired `Key::Home`, `Key::End`, `Key::Char('g')`, `Key::Char('G')` in + the Process tab dispatcher. + +The keypresses are tab-aware (only operate on the Process tab; +g/G on other tabs is a no-op so we don't trample the future Per-CPU +g/G bindings). + +## 60. v1.36 Mouse Click Positions Cursor (2026-06-21) + +Mouse support: left-click positions the Process cursor at the +clicked row, wheel scrolls up/down by 1 row, right-click opens the +PID detail popup for the clicked PID. Implemented as +`process_cursor_at_y(y, first_data_y)` in `App`, called from +`handle_mouse()` in `main.rs`. The `first_data_y` accounts for the +panel title, blank line, and column header so the click row +correctly maps to the data row. + +## 61. v1.37 Audit-Fix Release (2026-06-21) + +After the v1.32-v1.36 batch, an internal+external audit (oracle + +external htop/btop cross-reference) found 4 real bugs in the new +code that the test suite had missed: + +| # | Severity | What | Fix | +|---|----------|------|-----| +| 1 | CRITICAL | Sparkline storage was `VecDeque` containing f64 bits; renderer did `f64::from_bits()` reading integer 0..=255 as f64 bits → subnormal → 0. All sparklines blank. | Switch to `VecDeque` end-to-end. | +| 2 | HIGH | `tree_prefix` vertical bars used `│` only at the top level; nested ancestors got only `└─` instead of `│ │ └─`. | Walk ancestor chain, emit `│` for each non-last level. | +| 3 | MEDIUM | Mouse `y` was off by 3 (panel title, blank, header) in `process_cursor_at_y`. | Pass `first_data_y` and `y.saturating_sub(first_data_y)`. | +| 4 | LOW | Right-click filter on cursor wasn't actually opening PID detail; was a no-op. | Wire right-click to `pid_detail::PidDetail::read(pid)`. | + +Plus 2 htop parity features: + +- **Re-click-to-expand**: a second click on the same Per-CPU row + toggles expand (single-click = select). Implemented via + `last_clicked_cpu` and `expanded_cpu` fields on App. +- **PageUp/PageDown tests**: `page_selection(±1)` was wired but + untested for the Process tab. Added 1 regression test. + +Total: 4 audit fixes + 2 parity features + 5 new tests. +**140/140 tests pass.** + +## 62. v1.38 Audit-Fix Release: set_tab + Mouse Filter + SortDir + Cmdline + io_priority + Per-Disk Sparkline (2026-06-21) + +After v1.37, another internal+external audit found 2 new bugs in +v1.37's new code (audit-fix discipline in action) plus added 4 +htop/btop parity features: + +### 62.1 Audit fixes + +- **set_tab() centralization**: tab keypresses now route through + `App::set_tab(TabId)` which clears `last_clicked_cpu` and + `expanded_cpu`. v1.37 set these in 2 places (tab keys and + re-click-to-expand) and the tab keys forgot to clear + `last_clicked_cpu` → re-click-to-expand would unexpectedly + toggle expand on the FIRST click after a tab switch (because + `last_clicked_cpu` retained the OLD Per-CPU row's index). + v1.38 fix: every tab keypress calls `set_tab()` which does + the clearing in one place. +- **Mouse filter bug**: `process_cursor_at_y()` walked the + pre-filter list. If a filter was active, the click row mapped + to a hidden process. v1.38 fix: walk the post-filter + `visible_processes()` list and count only visible rows. + +### 62.2 Parity features + +- **SortDir + `i` key**: process sort now has a direction + (ascending/descending), `i` toggles it. Default: descending + (matches htop). +- **cmdline in PID detail**: read `/proc//cmdline` (NUL + separators → spaces, trailing NUL stripped). Renders as + "Cmdline: /usr/bin/foo --arg1 --arg2". +- **io_priority in PID detail**: read `/proc//stat` field + 18 (1-indexed) / `fields[15]` (0-indexed). Rendered as + "IO priority: N". +- **Per-disk sparkline**: 12-sample × 6.5s throughput history + per disk device, similar to the per-PID IO-RATE pattern. + +### 62.3 v1.38.1 hotfix + +`io_priority` was reading the WRONG field — `fields[44]` +(overall field 47) which on modern Linux kernels is a memory +address (~9×10¹³) that overflows u32 and silently returns None +for every process. The audit caught this. v1.38.1: + +- Field index changed to `fields[15]` (overall field 18). +- Regression test strengthened: read `/proc/self/stat` directly, + assert the value matches the function output, AND sanity-check + `< 1_000_000_000` (catches "reading a memory address" failure + mode by detecting values too large to be a real priority). + +**149/149 tests pass as of v1.38.1.** + +## 63. v1.39 (2026-06-21) + +Three small htop parity + UX improvements: + +| # | Feature | Files | +|---|---------|-------| +| 1 | Cursor preservation across sort: `o` and `i` no longer reset the cursor to row 0. The cursor follows the selected PID. | `app.rs:remember_and_restore_cursor()`; `main.rs:Key::Char('o')` + `Key::Char('i')` | +| 2 | Per-thread IO rate column: `T-IO` shows `io_total_rate / num_threads` (or "—" when threads ≤ 0). htop parity. | `process.rs:io_per_thread_rate_kbs()`; `render.rs` Process panel | +| 3 | Process environ in PID detail: read `/proc//environ`, render first 8 KEY=VALUE pairs sorted by key. htop F7 parity. | `pid_detail.rs:read_environ()`; `render.rs:render_pid_detail` | + +**158/158 tests pass.** + +### 63.1 What was NOT changed (intentional) + +- **Persistent config.toml** — the `ProcessInfo` filter, sort + mode, sort direction, and folded set are in-memory only. + Persisting them across `redbear-power` restarts would need a + config file (`~/.config/redbear-power/config.toml`) and a + load+save hook. Defer to v1.40. +- **Per-thread IO aggregation** (reading `/proc//status:Cpus_allowed_list` and + tracking it. Defer to v1.40. +- **History reclaim** for the 4 history maps (`io_history`, + `cpu_history`, `rss_history`, `disk_history`) — when a PID + exits, its `VecDeque` is currently never removed. Over + a long uptime with thousands of short-lived procs, this + could grow. The `BTreeMap` doesn't auto-remove. Defer to + v1.40 with an LRU cap. + ## See Also - **`local/docs/RATATUI-APP-PATTERNS.md`** §13 — the canonical ratatui 0.30 best-practices update that this plan is derived from. Includes the modular crate split, `WidgetRef`/`StatefulWidgetRef` notes, `Frame::count()`, `Stylize`, `Rect::centered`, custom widget patterns, layout destructuring, `Tabs` widget, async event handling (crossterm only), and the migration status table. Use this as the implementation guide while this doc is the roadmap. diff --git a/local/recipes/system/redbear-power/source/src/app.rs b/local/recipes/system/redbear-power/source/src/app.rs index 3f87ee4130..5322af94c9 100644 --- a/local/recipes/system/redbear-power/source/src/app.rs +++ b/local/recipes/system/redbear-power/source/src/app.rs @@ -167,6 +167,10 @@ pub meminfo: crate::meminfo::MemInfo, /// Cursor index into the visible (post-filter) process list. /// Distinct from `table_state` which tracks the Per-CPU tab. pub process_cursor: usize, + /// PID currently held by the cursor. The next refresh + /// (after a sort or direction toggle) restores the cursor + /// to this PID so the user doesn't lose their place. v1.39. + pub remembered_pid: Option, /// Last CPU id clicked by the mouse. A second click on the /// same row toggles expand (mirrors htop). `None` until /// the first click. @@ -350,6 +354,7 @@ impl App { rss_history: std::collections::BTreeMap::new(), disk_history: std::collections::BTreeMap::new(), process_cursor: 0, + remembered_pid: None, pid_detail: None, refresh_counter: 0, } @@ -829,6 +834,29 @@ impl App { self.process_cursor = row_offset.min(visible.len() - 1); } + /// Capture the PID currently at the cursor, then + /// immediately restore the cursor to that PID. Used by + /// sort/direction toggles (`o` / `i`) to keep the cursor + /// on the same process after re-sort. v1.39. + pub fn remember_and_restore_cursor(&mut self) { + // Step 1: remember the current cursor's PID (if any). + if let Some(pid) = self.selected_pid() { + self.remembered_pid = Some(pid); + } + // Step 2: try to restore the cursor to the remembered + // PID in the visible (post-filter) list. process_cursor + // is bounded by visible.len(), not by processes.len(), + // so we must search the visible list. If the process + // has exited or is hidden by the filter, the cursor + // stays at 0. + if let Some(target) = self.remembered_pid { + let visible = self.visible_processes(); + if let Some(vi) = visible.iter().position(|p| p.pid == target) { + self.process_cursor = vi; + } + } + } + /// Return the count of visible (post-filter) processes. The /// Process tab renders this many rows and `process_cursor` is /// bounded to this count. @@ -1414,4 +1442,74 @@ mod tests { app.page_selection(-1); // PageUp -1 page assert_eq!(app.process_cursor, 11); } + + #[test] + fn remember_and_restore_cursor_follows_pid_across_sort() { + // v1.39 regression test. When the user toggles sort + // mode ('o') or direction ('i'), the cursor must stay + // on the same PID even though the rows have been + // re-ordered. This is the visible behavior htop has; + // a "cursor reset to row 0 on every sort" feels like + // a bug. + let mut app = make_app_with_processes(10); // pids 100..109 + app.process_cursor = 0; + // Simulate the user landing on pid 100. + let target_pid = app.selected_pid(); + assert_eq!(target_pid, Some(100)); + // After the remember/restore cycle, the cursor should + // still point at pid 100 even though the sort may have + // moved it within the visible list. + app.remember_and_restore_cursor(); + let new_pid = app.selected_pid(); + assert_eq!(new_pid, Some(100), "cursor must follow the same PID"); + } + + #[test] + fn remember_and_restore_cursor_respects_filter() { + // v1.39. If the filter is active, the cursor must + // restore to the row matching the target PID in the + // visible (filtered) list, not to a row matching the + // original index. Test procs have comm "proc0".."proc9" + // and pids 100..109. + // Step 1: position the cursor on a known PID before + // the filter is applied. The cursor index here is + // a filtered index (the unfiltered list is identical + // to the filtered list at this point, so both work). + let mut app = make_app_with_processes(10); + app.process_cursor = 7; // pid 107, comm "proc7" + assert_eq!(app.selected_pid(), Some(107)); + // Step 2: remember the cursor's PID. + app.remember_and_restore_cursor(); + assert_eq!(app.remembered_pid, Some(107)); + // Step 3: apply a filter that hides the first seven + // rows. After this, the visible list is procs 7, 8, 9 + // (3 entries). pid 107 is at filtered index 0. + app.process_filter = "proc7".to_string(); + // selected_pid at this point is None (cursor=7 is + // out of bounds in a 3-row visible list), so the + // remembered_pid (from step 2) is what we restore. + app.remember_and_restore_cursor(); + // Cursor must land on pid 107 (filtered index 0), + // not stay at the out-of-bounds filtered index 7. + assert_eq!(app.process_cursor, 0, + "cursor must clamp to filtered list after remember+restore"); + assert_eq!(app.selected_pid(), Some(107), + "cursor must restore to PID even when filter hides prior rows"); + } + + #[test] + fn remember_and_restore_cursor_falls_back_when_pid_exits() { + // v1.39. If the remembered PID is no longer in the + // process list (the process exited between the user's + // sort key and the next refresh), the cursor falls + // back to 0 rather than panicking or pointing off- + // the-end of the list. + let mut app = make_app_with_processes(10); + app.process_cursor = 0; // pid 100 + app.remembered_pid = Some(999); // PID 999 doesn't exist + app.remember_and_restore_cursor(); + // Cursor should remain at 0 (no panic, no out-of- + // bounds write). + assert_eq!(app.process_cursor, 0); + } } \ No newline at end of file diff --git a/local/recipes/system/redbear-power/source/src/main.rs b/local/recipes/system/redbear-power/source/src/main.rs index 80c32a5fba..8f8cf6ea60 100644 --- a/local/recipes/system/redbear-power/source/src/main.rs +++ b/local/recipes/system/redbear-power/source/src/main.rs @@ -598,7 +598,9 @@ fn main() -> io::Result<()> { )); } Key::Char('o') => { + app.remember_and_restore_cursor(); app.process_sort = app.process_sort.next(); + app.remember_and_restore_cursor(); app.flash_status(format!( "process sort: {} {}", app.process_sort.name(), @@ -607,7 +609,9 @@ fn main() -> io::Result<()> { } Key::Char('i') => { // htop parity: invert sort direction. + app.remember_and_restore_cursor(); app.sort_ascending = !app.sort_ascending; + app.remember_and_restore_cursor(); app.flash_status(format!( "process sort: {} {}", app.process_sort.name(), diff --git a/local/recipes/system/redbear-power/source/src/pid_detail.rs b/local/recipes/system/redbear-power/source/src/pid_detail.rs index d71a242858..f0ac637270 100644 --- a/local/recipes/system/redbear-power/source/src/pid_detail.rs +++ b/local/recipes/system/redbear-power/source/src/pid_detail.rs @@ -74,6 +74,14 @@ pub struct PidDetail { /// 47. v1.38: htop parity. `None` if absent (older /// kernels). pub io_priority: Option, + /// Process environment variables from + /// `/proc//environ`. v1.39: htop F7 parity. + /// Stored as a sorted `Vec<(String, String)>` (key, + /// value) so the popup can render a stable, predictable + /// list. `None` when the file is missing (process + /// exited) or unreadable (CAP_SYS_PTRACE on Linux for + /// another user's process). + pub environ: Option>, } impl PidDetail { @@ -87,6 +95,7 @@ impl PidDetail { smaps: read_smaps_rollup(pid), cmdline: read_cmdline(pid), io_priority: read_io_priority(pid), + environ: read_environ(pid), } } } @@ -140,6 +149,33 @@ fn read_io_priority(pid: u32) -> Option { fields[15].parse::().ok() } +/// Read process environment from `/proc//environ`. The +/// file is NUL-separated `KEY=VALUE` pairs. Lines without `=` +/// (some odd kernels emit a bare value) are preserved as +/// `(key, "")`. Returns `None` if the file is missing, +/// unreadable, or contains no NUL separators (which would +/// indicate a malformed environ). The result is sorted by key +/// (case-sensitive, byte-wise) for stable popup rendering. +fn read_environ(pid: u32) -> Option> { + let bytes = fs::read(format!("/proc/{pid}/environ")).ok()?; + if bytes.is_empty() || !bytes.contains(&0) { + return None; + } + let mut out: Vec<(String, String)> = bytes + .split(|b| *b == 0) + .filter(|chunk| !chunk.is_empty()) + .map(|chunk| { + let s = String::from_utf8_lossy(chunk); + match s.split_once('=') { + Some((k, v)) => (k.to_string(), v.to_string()), + None => (s.into_owned(), String::new()), + } + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + Some(out) +} + fn read_status(pid: u32) -> ProcStatus { let path = format!("{PROC_STATUS_PREFIX}{pid}{PROC_STATUS_SUFFIX}"); let Ok(content) = fs::read_to_string(&path) else { return ProcStatus::default() }; @@ -359,4 +395,82 @@ mod tests { fn read_io_priority_handles_missing_pid() { assert_eq!(read_io_priority(999_999_999), None); } + + #[test] + fn read_environ_parses_self_environ() { + // v1.39. The /proc/self/environ of the test + // process is guaranteed to be readable and + // non-empty. We don't assert on specific vars + // (the test runner's env is unknown) but we do + // assert the format is correct: every entry has + // a key, no empty keys, sorted by key. + let vars = read_environ(std::process::id()) + .expect("/proc/self/environ must be readable"); + assert!(!vars.is_empty()); + for (k, _) in &vars { + assert!(!k.is_empty(), + "env var keys must not be empty"); + } + for w in vars.windows(2) { + assert!(w[0].0 <= w[1].0, + "env vars must be sorted by key"); + } + } + + #[test] + fn read_environ_handles_missing_pid() { + // v1.39. None when the file is missing. + assert_eq!(read_environ(999_999_999), None); + } + + #[test] + fn read_environ_handles_value_with_equals() { + // v1.39. A value containing '=' must be split + // only on the FIRST '='. PATH=/usr/bin:/bin + // → ("PATH", "/usr/bin:/bin"). If we split on + // all '=' we'd get the wrong value. + let tmp = tempfile_for_environ(b"PATH=/usr/bin=evil\0FOO=bar\0"); + let vars = read_environ_for_test(&tmp); + assert_eq!(vars[0].0, "FOO"); + assert_eq!(vars[0].1, "bar"); + assert_eq!(vars[1].0, "PATH"); + assert_eq!(vars[1].1, "/usr/bin=evil"); + std::fs::remove_file(&tmp).ok(); + } + + fn tempfile_for_environ(content: &[u8]) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "redbear-power-environ-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&p, content).unwrap(); + p + } + + /// Read environ from a known path (not /proc//environ). + /// Mirror of `read_environ` with a parameterised path. + fn read_environ_for_test(path: &std::path::Path) -> Vec<(String, String)> { + let bytes = std::fs::read(path).unwrap(); + if bytes.is_empty() || !bytes.contains(&0) { + return Vec::new(); + } + let mut out: Vec<(String, String)> = bytes + .split(|b| *b == 0) + .filter(|chunk| !chunk.is_empty()) + .map(|chunk| { + let s = String::from_utf8_lossy(chunk); + match s.split_once('=') { + Some((k, v)) => (k.to_string(), v.to_string()), + None => (s.into_owned(), String::new()), + } + }) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } } diff --git a/local/recipes/system/redbear-power/source/src/process.rs b/local/recipes/system/redbear-power/source/src/process.rs index 9106391152..91efd24b9a 100644 --- a/local/recipes/system/redbear-power/source/src/process.rs +++ b/local/recipes/system/redbear-power/source/src/process.rs @@ -410,6 +410,24 @@ impl ProcessInfo { } } + /// Average IO rate per thread. htop parity: surfaces "which + /// processes are IO-heavy per worker thread" — useful for + /// identifying thread-pool processes that fan out disk + /// pressure across many workers (a process with 32 threads + /// and 100 KiB/s is heavier per-thread than one with 1 + /// thread and 50 KiB/s). Returns `None` when the total + /// rate is unknown OR when the thread count is unknown + /// (so a process with 0 threads doesn't silently report + /// "0 KiB/s per thread" which would mislead the operator). + pub fn io_per_thread_rate_kbs(&self) -> Option { + let total = self.io_total_rate_kbs()?; + if self.num_threads <= 0 { + None + } else { + Some(total / self.num_threads as f64) + } + } + pub fn format_memory_kb(kb: u64) -> String { const UNITS: &[&str] = &["KiB", "MiB", "GiB", "TiB"]; let mut value = kb as f64; @@ -1072,6 +1090,47 @@ mod io_sort_unit_tests { assert_eq!(p.io_total_rate_kbs(), None); } + #[test] + fn io_per_thread_rate_kbs_divides_by_threads() { + // v1.39. Per-thread rate surfaces thread-pool pressure + // that's hidden in the aggregate. A 32-thread process + // at 320 KiB/s is the same as a 1-thread process at + // 10 KiB/s in aggregate, but very different in + // operator-relevant "IO per worker" terms. + let mut p = make_proc(1, 100, 50); + p.io_read_rate_kbs = Some(60.0); + p.io_write_rate_kbs = Some(40.0); + p.num_threads = 4; + assert_eq!(p.io_per_thread_rate_kbs(), Some(25.0)); + } + + #[test] + fn io_per_thread_rate_kbs_none_when_total_unknown() { + // v1.39. If we don't know the total rate, we don't + // know the per-thread rate either. Return None + // rather than guess. + let mut p = make_proc(1, 100, 50); + p.num_threads = 4; + p.io_read_rate_kbs = None; + p.io_write_rate_kbs = None; + assert_eq!(p.io_per_thread_rate_kbs(), None); + } + + #[test] + fn io_per_thread_rate_kbs_none_when_zero_threads() { + // v1.39. A process reporting 0 threads is a data + // error, not "every thread has 0 KiB/s". Reporting + // 0 per thread would mislead the operator into + // ignoring a misread process. Return None so the + // column shows "—" and the operator knows the value + // is unknown. + let mut p = make_proc(1, 100, 50); + p.io_read_rate_kbs = Some(100.0); + p.io_write_rate_kbs = Some(100.0); + p.num_threads = 0; + assert_eq!(p.io_per_thread_rate_kbs(), None); + } + #[test] fn sort_by_io_rate_uses_total() { let mut ps = vec![ diff --git a/local/recipes/system/redbear-power/source/src/render.rs b/local/recipes/system/redbear-power/source/src/render.rs index e7a070fde2..ff45ab742f 100644 --- a/local/recipes/system/redbear-power/source/src/render.rs +++ b/local/recipes/system/redbear-power/source/src/render.rs @@ -943,7 +943,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { _ => "RSS", }; let header_str = format!( - " PID STATE PRIO NI THR CPU% IO RATE {:<11} IO-RATE CPU% RSS COMM", + " PID STATE PRIO NI THR CPU% IO RATE {:<11} T-IO IO-RATE CPU% RSS COMM", mem_header ); lines.push(Line::from(vec![ @@ -970,6 +970,10 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { Some(kbs) => crate::process::ProcessInfo::format_rate_kbs(kbs), None => "—".to_string(), }; + let per_thread_str = match p.io_per_thread_rate_kbs() { + Some(kbs) => crate::process::ProcessInfo::format_rate_kbs(kbs), + None => "—".to_string(), + }; let mem_str = match app.process_sort { crate::process::SortMode::VSize => { crate::process::ProcessInfo::format_memory_kb(p.vsize_kb) @@ -1007,7 +1011,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { theme::VALUE }; lines.push(Line::from(format!( - " {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {:<12} {:<6} {:<6} {}", + " {}{:<7} {} {:<4} {:<3} {:<3} {:<6} {:<11} {:<11} {:<11} {:<11} {:<12} {:<6} {:<6} {}", prefix, p.pid, p.state, @@ -1017,6 +1021,7 @@ pub fn render_process_panel<'a>(app: &'a App, focused: bool) -> Paragraph<'a> { format!("{:.1}", p.cpu_pct), io_str, rate_str, + per_thread_str, mem_str, io_spark, cpu_spark, @@ -1198,6 +1203,40 @@ pub fn render_pid_detail(detail: &crate::pid_detail::PidDetail, pid: u32) -> Par " IO priority: {}", io_pri ).set_style(theme::VALUE))); + // v1.39: environ (htop F7 parity). Render the first + // 8 vars as KEY=VALUE; the full list is unbounded so we + // truncate for the popup. Sorted by key in `read_environ` + // for a stable, predictable list. + lines.push(Line::from("")); + lines.push(Line::from("[environ]".set_style(theme::LABEL_BOLD))); + match &detail.environ { + Some(vars) if !vars.is_empty() => { + lines.push(Line::from(format!( + " ({} variables)", + vars.len() + ).set_style(theme::VALUE))); + for (k, v) in vars.iter().take(8) { + let v_trunc: String = v.chars().take(80).collect(); + lines.push(Line::from(format!( + " {}={}", + k, + if v_trunc.is_empty() { String::from("\"\"") } else { v_trunc } + ).set_style(theme::VALUE))); + } + if vars.len() > 8 { + lines.push(Line::from(format!( + " ... and {} more", + vars.len() - 8 + ).set_style(theme::VALUE))); + } + } + Some(_) => { + lines.push(Line::from(" (empty)").set_style(theme::VALUE)); + } + None => { + lines.push(Line::from(" (unavailable)").set_style(theme::VALUE)); + } + } lines.push(Line::from("")); lines.push(Line::from("[Memory]".set_style(theme::LABEL_BOLD))); lines.push(Line::from(format!(