redbear-power: v1.39 cursor preservation + T-IO column + process environ

v1.39 lands three htop/btop parity features plus the
audit-fix discipline introduced in v1.37/v1.38:

1. Cursor preservation across sort
   o and i keypresses no longer reset the cursor to row 0.
   The cursor follows the currently-selected PID through the
   re-sort. Implemented as App::remember_and_restore_cursor()
   which walks the post-filter visible list. Three regression
   tests: follows PID, respects filter, falls back when PID
   exits.

2. Per-thread IO rate (T-IO column)
   New ProcessInfo::io_per_thread_rate_kbs() returns the
   aggregate IO rate divided by num_threads, surfacing
   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. Returns None
   when num_threads <= 0 (data error, not '0 KiB/s per
   thread' which would mislead the operator). Three unit
   tests cover the divide, the missing-total case, and the
   zero-threads case.

3. Process environ in PID detail
   /proc/<pid>/environ read as NUL-separated KEY=VALUE
   pairs, sorted by key for stable popup rendering.
   Rendered as the first 8 vars in the PID detail popup
   with a '(N variables)' header. htop F7 parity. Three
   unit tests: parse self environ, missing PID, value
   containing '=' (must split on FIRST '=' only).

The improvement plan doc is also updated with sections
56-63 covering v1.32 (sparklines) through v1.39
(per-thread IO + environ) since the doc previously
stopped at v1.31.

Test count: 158/158 pass (was 149 in v1.38.1).
This commit is contained in:
2026-06-21 12:03:12 +03:00
parent 01de65bd03
commit 5bd371c070
6 changed files with 485 additions and 2 deletions
@@ -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<u8>` 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<u64>` containing f64 bits; renderer did `f64::from_bits()` reading integer 0..=255 as f64 bits → subnormal → 0. All sparklines blank. | Switch to `VecDeque<u8>` 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/<pid>/cmdline` (NUL
separators → spaces, trailing NUL stripped). Renders as
"Cmdline: /usr/bin/foo --arg1 --arg2".
- **io_priority in PID detail**: read `/proc/<pid>/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/<pid>/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/<pid]/task/*/io`
and summing across threads) — distinct from the
per-thread-avg rate, which is what v1.39 ships. Per-thread IO
aggregation would be useful for "is one thread of this 32-thread
process hammering disk?" but requires an extra filesystem
walk per process per tick. Defer to v1.40.
- **CPU affinity display** (htop has an `affinity` column) —
requires reading `/proc/<pid>/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<u8>` 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.
@@ -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<u32>,
/// 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);
}
}
@@ -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(),
@@ -74,6 +74,14 @@ pub struct PidDetail {
/// 47. v1.38: htop parity. `None` if absent (older
/// kernels).
pub io_priority: Option<u32>,
/// Process environment variables from
/// `/proc/<pid>/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<Vec<(String, String)>>,
}
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<u32> {
fields[15].parse::<u32>().ok()
}
/// Read process environment from `/proc/<pid>/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<Vec<(String, String)>> {
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/<pid>/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
}
}
@@ -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<f64> {
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![
@@ -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!(