From c361bd71e80f8eb1d7dc2bdeda1e173ff339aced Mon Sep 17 00:00:00 2001 From: Adam Hitchcock Date: Thu, 1 Oct 2026 00:18:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(panel):=20CPU%=20and=20memory=20per=20proc?= =?UTF-8?q?ess,=20and=20a=20total,=20in=20Info=20=E2=86=92=20Processes=20(?= =?UTF-8?q?#1064)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(panel): CPU% and memory per process, and a total, in Info → Processes The daemon adds rss, cpu_ns and a start stamp to each ProcEntry (serde default, so an old daemon or GUI skips them). The GUI turns two CPU-time samples into a ps-style % and sums both into a Total line. `tty7 procs` gains an RSS column. Co-Authored-By: Claude Opus 5.5 * fix(panel): no CPU% across a poll gap, and test CPU time is in ns A new poll chain starts after the panel was shut or the pane changed; comparing against the sample from before the gap showed a long-run average as the current figure. Reset the tracker so the first round shows a dash. Replace the self-usage test's cpu_ns > 0 (fails on Linux before the first 10 ms tick, passes unconverted mach ticks) with a check that the process's CPU time covers this thread's own burned CPU time. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- crates/tty7-cli/src/commands.rs | 1 + crates/tty7-cli/src/output.rs | 15 +- crates/tty7-core/Cargo.toml | 1 + crates/tty7-core/src/daemon/mod.rs | 1 + crates/tty7-core/src/daemon/procinfo.rs | 8 +- crates/tty7-core/src/daemon/procstat.rs | 249 +++++++++++++++++++++++ crates/tty7-core/src/daemon/protocol.rs | 12 +- crates/tty7-core/src/host/server.rs | 1 + src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 1 + src/ui/i18n/zh.rs | 1 + src/ui/mod.rs | 1 + src/ui/proc_usage.rs | 257 ++++++++++++++++++++++++ src/ui/right_panel.rs | 26 ++- 15 files changed, 564 insertions(+), 12 deletions(-) create mode 100644 crates/tty7-core/src/daemon/procstat.rs create mode 100644 src/ui/proc_usage.rs diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index fa53fc9a..b67168a0 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -4095,6 +4095,7 @@ mod tests { name: name.into(), depth, foreground, + ..Default::default() } } diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index 85639207..8ecd5cc6 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -289,10 +289,14 @@ pub fn procs_tables(procs: &PaneProcs) -> String { p.pid.to_string(), format!("{}{}", " ".repeat(p.depth as usize), p.name), if p.foreground { "*" } else { "" }.to_string(), + p.rss.map_or_else( + || "-".to_string(), + tty7_core::daemon::procstat::compact_bytes, + ), ] }) .collect(); - let mut out = table(&["PID", "NAME", "FG"], &rows); + let mut out = table(&["PID", "NAME", "FG", "RSS"], &rows); if !procs.ports.is_empty() { out.push('\n'); let rows: Vec> = procs @@ -586,12 +590,15 @@ mod tests { name: "pwsh".into(), depth: 0, foreground: false, + ..Default::default() }, ProcEntry { pid: 200, name: "cargo".into(), depth: 1, foreground: true, + rss: Some(38 * 1024 * 1024), + ..Default::default() }, ], ports: vec![PortEntry { @@ -613,6 +620,11 @@ mod tests { "the foreground process is marked: {rendered}" ); assert!(rendered.contains("3000"), "{rendered}"); + assert!(rendered.contains("RSS"), "{rendered}"); + assert!( + rendered.contains("38 MB"), + "rss is appended per row: {rendered}" + ); assert!( !rendered.contains("note:"), "a probe that worked says nothing: {rendered}" @@ -630,6 +642,7 @@ mod tests { name: "zsh".into(), depth: 0, foreground: true, + ..Default::default() }], ports: Vec::new(), probe: PortProbe::Unavailable("lsof: program not found".into()), diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index d0723765..3765ce59 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -196,6 +196,7 @@ windows-sys = { version = "0.59", features = [ "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", + "Win32_System_ProcessStatus", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", diff --git a/crates/tty7-core/src/daemon/mod.rs b/crates/tty7-core/src/daemon/mod.rs index 15e767d5..33cb9341 100644 --- a/crates/tty7-core/src/daemon/mod.rs +++ b/crates/tty7-core/src/daemon/mod.rs @@ -13,6 +13,7 @@ pub mod mobile; pub mod pane; pub mod pidfile; pub mod procinfo; +pub mod procstat; pub mod protocol; pub(crate) mod remote; pub mod remote_link; diff --git a/crates/tty7-core/src/daemon/procinfo.rs b/crates/tty7-core/src/daemon/procinfo.rs index e55581fb..2e7a29c3 100644 --- a/crates/tty7-core/src/daemon/procinfo.rs +++ b/crates/tty7-core/src/daemon/procinfo.rs @@ -31,7 +31,9 @@ pub fn snapshot(shell_pid: u32, fg_pgid: Option) -> PaneProcs { if let PortProbe::Unavailable(detail) = &probe { note_probe_failure(shell_pid, detail); } - finish(procs, ports, probe) + let mut out = finish(procs, ports, probe); + crate::daemon::procstat::fill(&mut out.procs); + out } /// How long the same probe failure waits before it is written down again. @@ -209,6 +211,7 @@ fn walk(table: &HashMap, shell_pid: u32, fg_pgid: Option) -> Vec< name: row.name.clone(), depth, foreground: fg_pgid.is_some_and(|g| g as u32 == row.pgid), + ..Default::default() }); if depth + 1 > MAX_DEPTH { continue; @@ -1071,18 +1074,21 @@ mod tests { name: "zsh".into(), depth: 0, foreground: false, + ..Default::default() }, ProcEntry { pid: 9000, name: "go".into(), depth: 1, foreground: true, + ..Default::default() }, ProcEntry { pid: 9001, name: "main".into(), depth: 2, foreground: true, + ..Default::default() }, ]; let report = "p9001\nf3\nn*:8080\nf5\nn[::]:8080\np100\n"; diff --git a/crates/tty7-core/src/daemon/procstat.rs b/crates/tty7-core/src/daemon/procstat.rs new file mode 100644 index 00000000..15217436 --- /dev/null +++ b/crates/tty7-core/src/daemon/procstat.rs @@ -0,0 +1,249 @@ +//! Resident memory and CPU time for the Info panel's process rows. +//! +//! Read per walked pid rather than in `process_table`'s pass over every pid on +//! the machine: the panel shows at most `MAX_PROCS` rows, and a failed read on +//! another user's process must not drop it from the tree. + +use crate::daemon::protocol::ProcEntry; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Usage { + pub rss: Option, + pub cpu_ns: Option, + pub started: Option, +} + +pub(crate) fn fill(procs: &mut [ProcEntry]) { + for p in procs { + let u = read(p.pid); + p.rss = u.rss; + p.cpu_ns = u.cpu_ns; + p.started = u.started; + } +} + +/// `512 KB`, `38 MB`, `1.2 GB`: binary units under the short names `ps` and +/// Activity Monitor print. +pub fn compact_bytes(n: u64) -> String { + const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"]; + if n < 1024 { + return format!("{n} B"); + } + let mut v = n as f64 / 1024.0; + let mut unit = 0; + while v >= 1024.0 && unit < UNITS.len() - 1 { + v /= 1024.0; + unit += 1; + } + match v < 10.0 { + true => format!("{v:.1} {}", UNITS[unit]), + false => format!("{v:.0} {}", UNITS[unit]), + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn read(pid: u32) -> Usage { + let mut info: libc::proc_taskallinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + // SAFETY: `info` is a correctly sized, writable `proc_taskallinfo`. + let ret = unsafe { + libc::proc_pidinfo( + pid as libc::c_int, + libc::PROC_PIDTASKALLINFO, + 0, + &mut info as *mut _ as *mut libc::c_void, + size, + ) + }; + if ret != size { + return Usage::default(); + } + let t = &info.ptinfo; + let b = &info.pbsd; + Usage { + rss: Some(t.pti_resident_size), + // Mach ticks, not nanoseconds: 1:1 on Intel, 125:3 on Apple silicon. + cpu_ns: Some(mach_to_ns(t.pti_total_user + t.pti_total_system)), + started: Some(b.pbi_start_tvsec * 1_000_000 + b.pbi_start_tvusec), + } +} + +#[cfg(target_os = "macos")] +// libc deprecates these in favour of `mach2`; not worth a dependency. +#[allow(deprecated)] +fn mach_to_ns(ticks: u64) -> u64 { + static TIMEBASE: std::sync::OnceLock<(u64, u64)> = std::sync::OnceLock::new(); + let (numer, denom) = *TIMEBASE.get_or_init(|| { + let mut tb = libc::mach_timebase_info { numer: 0, denom: 0 }; + // SAFETY: writes the two fields of `tb` and nothing else. + let ok = unsafe { libc::mach_timebase_info(&mut tb) } == 0 && tb.denom != 0; + match ok { + true => (tb.numer as u64, tb.denom as u64), + false => (1, 1), + } + }); + (ticks as u128 * numer as u128 / denom as u128) as u64 +} + +#[cfg(target_os = "linux")] +pub(crate) fn read(pid: u32) -> Usage { + match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(stat) => parse_stat( + &stat, + sysconf(libc::_SC_CLK_TCK), + sysconf(libc::_SC_PAGESIZE), + ), + Err(_) => Usage::default(), + } +} + +#[cfg(target_os = "linux")] +fn sysconf(name: libc::c_int) -> u64 { + // SAFETY: `sysconf` only reads a configuration value. + let v = unsafe { libc::sysconf(name) }; + if v > 0 { v as u64 } else { 0 } +} + +/// utime, stime, starttime and rss out of a `/proc//stat` line — fields +/// 14, 15, 22 and 24, counted from the `)` that closes the command name, which +/// may itself hold spaces. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn parse_stat(stat: &str, ticks_per_sec: u64, page: u64) -> Usage { + let Some(close) = stat.rfind(')') else { + return Usage::default(); + }; + let f: Vec = stat[close + 1..] + .split_whitespace() + .map(|s| s.parse().unwrap_or(0)) + .collect(); + if f.len() < 22 || ticks_per_sec == 0 { + return Usage::default(); + } + Usage { + rss: (page > 0).then(|| f[21] * page), + cpu_ns: Some((f[11] + f[12]) * (1_000_000_000 / ticks_per_sec)), + started: Some(f[19]), + } +} + +#[cfg(windows)] +pub(crate) fn read(pid: u32) -> Usage { + use windows_sys::Win32::Foundation::{CloseHandle, FILETIME}; + use windows_sys::Win32::System::ProcessStatus::{ + K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, + }; + use windows_sys::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + let ft = |t: FILETIME| (t.dwHighDateTime as u64) << 32 | t.dwLowDateTime as u64; + let mut u = Usage::default(); + // SAFETY: every out-pointer is a zeroed local of the type the call expects, + // and the handle is closed on the only path that opened it. + unsafe { + let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if h.is_null() { + return u; + } + let (mut created, mut exited, mut kernel, mut user): ( + FILETIME, + FILETIME, + FILETIME, + FILETIME, + ) = std::mem::zeroed(); + if GetProcessTimes(h, &mut created, &mut exited, &mut kernel, &mut user) != 0 { + // FILETIME is in 100 ns units. + u.cpu_ns = Some((ft(kernel) + ft(user)) * 100); + u.started = Some(ft(created)); + } + let mut mem: PROCESS_MEMORY_COUNTERS = std::mem::zeroed(); + let cb = std::mem::size_of::() as u32; + if K32GetProcessMemoryInfo(h, &mut mem, cb) != 0 { + u.rss = Some(mem.WorkingSetSize as u64); + } + CloseHandle(h); + } + u +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +pub(crate) fn read(_pid: u32) -> Usage { + Usage::default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_read_compactly() { + assert_eq!(compact_bytes(512), "512 B"); + assert_eq!(compact_bytes(512 * 1024), "512 KB"); + assert_eq!(compact_bytes(38 * 1024 * 1024), "38 MB"); + assert_eq!(compact_bytes(1_288_490_189), "1.2 GB"); + assert_eq!(compact_bytes(5 * 1024 * 1024 + 300 * 1024), "5.3 MB"); + } + + #[test] + fn an_old_daemons_entry_has_no_usage() { + let old = r#"{"pid":1,"name":"zsh","depth":0,"foreground":true}"#; + let p: ProcEntry = serde_json::from_str(old).unwrap(); + assert_eq!((p.rss, p.cpu_ns, p.started), (None, None, None)); + assert_eq!( + serde_json::to_string(&p).unwrap(), + old, + "no nulls sent back" + ); + } + + #[test] + fn a_stat_line_with_spaces_in_the_name_parses() { + let stat = "42 (tmux: server) S 1 42 42 0 -1 4194560 100 0 0 0 250 50 0 0 20 0 1 0 \ + 9000 12345678 2048 18446744073709551615"; + let u = parse_stat(stat, 100, 4096); + assert_eq!(u.rss, Some(2048 * 4096)); + assert_eq!(u.cpu_ns, Some(3_000_000_000)); + assert_eq!(u.started, Some(9000)); + assert_eq!(parse_stat("garbage", 100, 4096), Usage::default()); + } + + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn this_process_reports_plausible_usage() { + let u = read(std::process::id()); + let rss = u.rss.expect("own rss"); + assert!((1 << 20..1 << 40).contains(&rss), "rss {rss}"); + assert!(u.cpu_ns.is_some(), "{u:?}"); + assert!(u.started.is_some_and(|s| s > 0), "{u:?}"); + } + + /// The process's CPU time is at least this thread's, in the same unit. + /// Unconverted mach ticks read ~42x low on Apple silicon; a bare `> 0` + /// would pass them, and would fail on Linux before the first 10 ms tick. + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[test] + fn cpu_time_is_in_nanoseconds() { + fn thread_ns() -> u64 { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: writes `ts` and nothing else. + unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64 + } + let before = read(std::process::id()).cpu_ns.expect("cpu time"); + let t0 = thread_ns(); + let mut x = 0u64; + while thread_ns() - t0 < 200_000_000 { + x = std::hint::black_box(x.wrapping_mul(31).wrapping_add(1)); + } + let burned = thread_ns() - t0; + let after = read(std::process::id()).cpu_ns.expect("cpu time"); + // Slack for Linux's 10 ms clock tick on each end. + assert!( + after - before + 30_000_000 >= burned, + "process cpu went up {} ns while this thread alone burned {burned}", + after - before + ); + } +} diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 099402a9..04ff4ec8 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -464,13 +464,23 @@ pub struct ManagedForward { pub enabled: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ProcEntry { pub pid: u32, pub name: String, pub depth: u8, #[serde(default)] pub foreground: bool, + /// Resident memory in bytes, total CPU time (user + system) in ns, and an + /// opaque start stamp that tells a reused pid from the process it replaced. + /// `None` from a daemon that predates them or could not read them; not a + /// `PROTOCOL_VERSION` bump, since either side skips what it does not know. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rss: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu_ns: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 725238b4..58fc967d 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -1831,6 +1831,7 @@ mod aggregate_tests { name: "node".into(), depth: 0, foreground: true, + ..Default::default() }], ports: vec![crate::daemon::protocol::PortEntry { port: 3000, diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index fb41959b..67048332 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1379,6 +1379,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelNoChangesHint => "The working tree is clean.", L10nKey::PanelSessionSubtitle => "Session", L10nKey::PanelProcessesSubtitle => "Processes", + L10nKey::PanelProcessesTotal => "Total", L10nKey::PanelPortsSubtitle => "Ports", L10nKey::PanelPortsUnsupported => "That machine's tty7-server is too old to list ports.", L10nKey::PanelPortsProbeFailed => "Couldn't check what this pane is listening on.", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 8fa911e8..21beab5d 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1441,6 +1441,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelNoChangesHint => "ワーキングツリーはクリーンです", L10nKey::PanelSessionSubtitle => "セッション", L10nKey::PanelProcessesSubtitle => "プロセス", + L10nKey::PanelProcessesTotal => "合計", L10nKey::PanelPortsSubtitle => "ポート", L10nKey::PanelLatency => "遅延", L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index a2fe969b..787b3fd2 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1076,6 +1076,7 @@ l10n_keys! { PanelMoreChangedFiles, PanelSessionSubtitle, PanelProcessesSubtitle, + PanelProcessesTotal, PanelPortsSubtitle, PanelPortsUnsupported, PanelPortsProbeFailed, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 373c3c2a..bbe9d48a 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1287,6 +1287,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelNoChangesHint => "worktree 是干净的。", L10nKey::PanelSessionSubtitle => "会话", L10nKey::PanelProcessesSubtitle => "进程", + L10nKey::PanelProcessesTotal => "合计", L10nKey::PanelPortsSubtitle => "端口", L10nKey::PanelLatency => "延迟", L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。", diff --git a/src/ui/mod.rs b/src/ui/mod.rs index af4dc65d..d87d2477 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -35,6 +35,7 @@ pub mod pending_pane; pub mod perf; pub mod prefill; pub mod presets; +pub mod proc_usage; pub mod prompt; pub mod remote_connect; pub mod remote_workspace; diff --git a/src/ui/proc_usage.rs b/src/ui/proc_usage.rs new file mode 100644 index 00000000..0754feb1 --- /dev/null +++ b/src/ui/proc_usage.rs @@ -0,0 +1,257 @@ +//! CPU% and memory for Info → Processes. +//! +//! The daemon sends raw CPU time; the percentage needs two samples of the same +//! process, so it is worked out here, where the panel's poll keeps state +//! between rounds and the protocol stays stateless. + +use std::collections::HashMap; +use std::time::Instant; + +use gpui::{App, IntoElement, ParentElement as _, SharedString, Styled as _, div, px, rems}; +use gpui_component::{ActiveTheme as _, h_flex}; + +use crate::daemon::procstat::compact_bytes; +use crate::daemon::protocol::ProcEntry; +use crate::ui::i18n::{L10nKey, t}; +use crate::ui::right_panel::{META, META_MONO}; + +const CPU_W: f32 = 40.; +const MEM_W: f32 = 52.; +const PID_W: f32 = 44.; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Sample { + started: Option, + cpu_ns: u64, +} + +#[derive(Default)] +pub(crate) struct CpuTracker { + prev: HashMap, + at: Option, + pct: HashMap, +} + +impl CpuTracker { + pub(crate) fn sample(&mut self, procs: &[ProcEntry], now: Instant) { + let elapsed_ns = self + .at + .map_or(0, |at| now.saturating_duration_since(at).as_nanos() as u64); + let mut next = HashMap::new(); + self.pct.clear(); + for p in procs { + let Some(cpu_ns) = p.cpu_ns else { continue }; + let cur = Sample { + started: p.started, + cpu_ns, + }; + if let Some(pct) = cpu_percent(self.prev.get(&p.pid).copied(), cur, elapsed_ns) { + self.pct.insert(p.pid, pct); + } + next.insert(p.pid, cur); + } + self.prev = next; + self.at = Some(now); + } + + pub(crate) fn percent(&self, pid: u32) -> Option { + self.pct.get(&pid).copied() + } +} + +/// `ps`'s %CPU over the interval: 100 is one core busy the whole time. `None` +/// for a first sample, a pid now naming a different process, a clock that did +/// not move, or a CPU time that went backwards. +fn cpu_percent(prev: Option, cur: Sample, elapsed_ns: u64) -> Option { + let prev = prev?; + if elapsed_ns == 0 || prev.started != cur.started || cur.cpu_ns < prev.cpu_ns { + return None; + } + Some((cur.cpu_ns - prev.cpu_ns) as f64 * 100.0 / elapsed_ns as f64) +} + +/// Summed CPU% and RSS over the rows that report them; `None` when none do. +pub(crate) fn totals(procs: &[ProcEntry], cpu: &CpuTracker) -> (Option, Option) { + let pcts: Vec = procs.iter().filter_map(|p| cpu.percent(p.pid)).collect(); + let rss: Vec = procs.iter().filter_map(|p| p.rss).collect(); + ( + (!pcts.is_empty()).then(|| pcts.iter().sum()), + (!rss.is_empty()).then(|| rss.iter().sum()), + ) +} + +fn format_cpu(pct: f64) -> String { + match pct < 10.0 { + true => format!("{pct:.1}%"), + false => format!("{pct:.0}%"), + } +} + +/// The CPU cell: blank for a daemon that sends no CPU time, a dash for a +/// process seen once so far. +fn cpu_text(reported: bool, pct: Option) -> String { + match (reported, pct) { + (_, Some(pct)) => format_cpu(pct), + (true, None) => "–".into(), + (false, None) => String::new(), + } +} + +fn cell(text: String, w: f32, mono: SharedString, cx: &App) -> impl IntoElement { + div() + .flex_none() + .w(px(w)) + .flex() + .justify_end() + .text_size(rems(META_MONO)) + .font_family(mono) + .text_color(cx.theme().muted_foreground) + .child(text) +} + +/// CPU%, memory and pid, right-aligned in fixed columns so rows line up. +pub(crate) fn row_cells( + p: &ProcEntry, + cpu: &CpuTracker, + mono: SharedString, + cx: &App, +) -> impl IntoElement { + h_flex() + .flex_none() + .gap(px(6.)) + .child(cell( + cpu_text(p.cpu_ns.is_some(), cpu.percent(p.pid)), + CPU_W, + mono.clone(), + cx, + )) + .child(cell( + p.rss.map(compact_bytes).unwrap_or_default(), + MEM_W, + mono.clone(), + cx, + )) + .child(cell(p.pid.to_string(), PID_W, mono, cx)) +} + +/// The Total line under the tree, or nothing from a daemon that reports no +/// usage at all. +pub(crate) fn total_row( + procs: &[ProcEntry], + cpu: &CpuTracker, + mono: SharedString, + cx: &App, +) -> Option { + let reported = procs.iter().any(|p| p.cpu_ns.is_some()); + let (pct, rss) = totals(procs, cpu); + if !reported && rss.is_none() { + return None; + } + Some( + h_flex() + .h(px(super::right_panel::PROC_ROW_H)) + .px(px(super::right_panel::ROW_INSET)) + .items_center() + .gap(px(6.)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(rems(META)) + .text_color(cx.theme().muted_foreground) + .child(t(L10nKey::PanelProcessesTotal).to_string()), + ) + .child( + h_flex() + .flex_none() + .gap(px(6.)) + .child(cell(cpu_text(reported, pct), CPU_W, mono.clone(), cx)) + .child(cell( + rss.map(compact_bytes).unwrap_or_default(), + MEM_W, + mono.clone(), + cx, + )) + .child(cell(String::new(), PID_W, mono, cx)), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn s(started: u64, cpu_ns: u64) -> Sample { + Sample { + started: Some(started), + cpu_ns, + } + } + + #[test] + fn cpu_percent_is_ps_style() { + assert_eq!( + cpu_percent(Some(s(1, 0)), s(1, 500_000_000), 1_000_000_000), + Some(50.0) + ); + assert_eq!( + cpu_percent(Some(s(1, 0)), s(1, 2_000_000_000), 1_000_000_000), + Some(200.0) + ); + } + + #[test] + fn no_percent_without_a_fair_comparison() { + assert_eq!(cpu_percent(None, s(1, 10), 1_000), None, "first sample"); + assert_eq!( + cpu_percent(Some(s(1, 0)), s(2, 10), 1_000), + None, + "pid reused" + ); + assert_eq!( + cpu_percent(Some(s(1, 0)), s(1, 10), 0), + None, + "zero interval" + ); + assert_eq!( + cpu_percent(Some(s(1, 50)), s(1, 10), 1_000), + None, + "went backwards" + ); + } + + fn proc(pid: u32, rss: Option, cpu_ns: Option) -> ProcEntry { + ProcEntry { + pid, + rss, + cpu_ns, + started: Some(7), + ..Default::default() + } + } + + #[test] + fn totals_sum_what_was_reported() { + let t0 = Instant::now(); + let mut cpu = CpuTracker::default(); + cpu.sample(&[proc(1, None, Some(0)), proc(2, None, Some(0))], t0); + assert_eq!(cpu.percent(1), None, "first sample shows a dash"); + let procs = [ + proc(1, Some(1024), Some(250_000_000)), + proc(2, Some(2048), Some(500_000_000)), + proc(3, None, None), + ]; + cpu.sample(&procs, t0 + Duration::from_secs(1)); + assert_eq!(totals(&procs, &cpu), (Some(75.0), Some(3072))); + assert_eq!(totals(&[proc(3, None, None)], &cpu), (None, None)); + } + + #[test] + fn cpu_cells_read_blank_dash_or_percent() { + assert_eq!(cpu_text(false, None), ""); + assert_eq!(cpu_text(true, None), "–"); + assert_eq!(cpu_text(true, Some(12.4)), "12%"); + assert_eq!(cpu_text(true, Some(0.34)), "0.3%"); + } +} diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 3f8e0067..ce1ddd49 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -102,7 +102,7 @@ const INFO_ROW_H: f32 = 28.; /// How tall a process row is — a notch tighter than the Session rows, since /// the list is read as one block rather than line by line. -const PROC_ROW_H: f32 = 26.; +pub(crate) const PROC_ROW_H: f32 = 26.; /// How far a child process sits in from its parent, the width of the `└` /// elbow that marks it. @@ -252,6 +252,7 @@ const SEARCH_GLYPH_GAP: f32 = 3.; pub(crate) struct RightPanelState { pub(crate) procs_pane: Option, pub(crate) procs: Option, + pub(crate) cpu: crate::ui::proc_usage::CpuTracker, pub(crate) procs_loading: bool, pub(crate) procs_gen: u64, pub(crate) procs_forwards: Option, @@ -1352,16 +1353,16 @@ impl Tty7App { .when(p.foreground, |d| d.font_weight(gpui::FontWeight::MEDIUM)) .child(p.name.clone()), ) - .child( - div() - .flex_none() - .text_size(rems(META_MONO)) - .font_family(mono.clone()) - .text_color(cx.theme().muted_foreground) - .child(p.pid.to_string()), - ), + .child(crate::ui::proc_usage::row_cells( + p, + &self.right_panel.cpu, + mono.clone(), + cx, + )), ); } + let total = crate::ui::proc_usage::total_row(procs, &self.right_panel.cpu, mono, cx); + let list = list.children(total); Some( v_flex() .child(self.panel_subtitle(t(L10nKey::PanelProcessesSubtitle), None, cx)) @@ -1872,6 +1873,10 @@ impl Tty7App { // empties. if !self.right_panel.procs_loading && self.procs_wanted() { self.right_panel.procs_loading = true; + // A new chain follows a gap — the panel was shut, or the pane + // changed — and a CPU% across the gap would be its long average + // shown as if it were now. A dash for one round is honest. + self.right_panel.cpu = Default::default(); let generation = self.right_panel.procs_gen; self.spawn_procs_query(pane_id, generation, forwards, host, cx); } @@ -1929,6 +1934,9 @@ impl Tty7App { // answer with in place: blanking it on one failed poll // would make the panel flicker on a link that hiccups. if let Some(procs) = procs { + app.right_panel + .cpu + .sample(&procs.procs, std::time::Instant::now()); app.right_panel.procs = Some(procs); } if forwards.is_some() {