feat(panel): CPU% and memory per process, and a total, in Info → Processes (#1064)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
Adam Hitchcock
2026-10-01 15:18:27 +08:00
committed by GitHub
co-authored by Claude Opus 5.5 l0ng-ai
parent e1f93704fe
commit c361bd71e8
15 changed files with 564 additions and 12 deletions
+1
View File
@@ -4095,6 +4095,7 @@ mod tests {
name: name.into(),
depth,
foreground,
..Default::default()
}
}
+14 -1
View File
@@ -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<Vec<String>> = 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()),
+1
View File
@@ -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",
+1
View File
@@ -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;
+7 -1
View File
@@ -31,7 +31,9 @@ pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> 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<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> 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";
+249
View File
@@ -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<u64>,
pub cpu_ns: Option<u64>,
pub started: Option<u64>,
}
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::<libc::proc_taskallinfo>() 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/<pid>/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<u64> = 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::<PROCESS_MEMORY_COUNTERS>() 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
);
}
}
+11 -1
View File
@@ -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<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_ns: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+1
View File
@@ -1831,6 +1831,7 @@ mod aggregate_tests {
name: "node".into(),
depth: 0,
foreground: true,
..Default::default()
}],
ports: vec![crate::daemon::protocol::PortEntry {
port: 3000,
+1
View File
@@ -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.",
+1
View File
@@ -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 が古く、ポートを列挙できません。",
+1
View File
@@ -1076,6 +1076,7 @@ l10n_keys! {
PanelMoreChangedFiles,
PanelSessionSubtitle,
PanelProcessesSubtitle,
PanelProcessesTotal,
PanelPortsSubtitle,
PanelPortsUnsupported,
PanelPortsProbeFailed,
+1
View File
@@ -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 太旧,列不出端口。",
+1
View File
@@ -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;
+257
View File
@@ -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<u64>,
cpu_ns: u64,
}
#[derive(Default)]
pub(crate) struct CpuTracker {
prev: HashMap<u32, Sample>,
at: Option<Instant>,
pct: HashMap<u32, f64>,
}
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<f64> {
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<Sample>, cur: Sample, elapsed_ns: u64) -> Option<f64> {
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<f64>, Option<u64>) {
let pcts: Vec<f64> = procs.iter().filter_map(|p| cpu.percent(p.pid)).collect();
let rss: Vec<u64> = 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<f64>) -> 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<gpui::Div> {
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<u64>, cpu_ns: Option<u64>) -> 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%");
}
}
+17 -9
View File
@@ -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<u64>,
pub(crate) procs: Option<PaneProcs>,
pub(crate) cpu: crate::ui::proc_usage::CpuTracker,
pub(crate) procs_loading: bool,
pub(crate) procs_gen: u64,
pub(crate) procs_forwards: Option<crate::ui::app::ForwardRoute>,
@@ -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() {