diff --git a/CHANGELOG.md b/CHANGELOG.md index cb040d1..0ac5ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ This project follows a Keep a Changelog-style format and uses version tags for r ## [Unreleased] +## [1.9.9] - 2026-08-13 + +### Fixed + +- **The process table no longer sits blank after `p`.** Toggling it on cannot + produce rows for up to two intervals (~4s at the default): the sample already + in flight was started with the process walk off, so the first snapshot back + carries no processes. The bordered panel rendered empty for that whole window + and read as a broken or hung table. It now shows `collecting process + samples…` until the first walk lands — or `paused — press space to resume` + when polling is paused, where no sample is coming at all. An empty table with + collection running now says `no processes match filter` instead of nothing. + ## [1.9.8] - 2026-08-13 **Skip 1.9.1 through 1.9.7 — install this one instead.** Those seven were cut in diff --git a/actop/tui/app.py b/actop/tui/app.py index 85d5e0e..0729ba4 100644 --- a/actop/tui/app.py +++ b/actop/tui/app.py @@ -318,6 +318,11 @@ def __init__(self, args) -> None: self._last_cpu_watts = 0.0 self._last_gpu_watts = 0.0 self._show_processes = bool(self._config.show_processes) + # True until a snapshot that actually carried a process walk arrives, so + # the table can explain the wait rather than sit blank (up to ~2 + # intervals after `p`, since the in-flight sample was started with the + # walk off). + self._processes_pending = self._show_processes self._splash_frame = 0 self._sampler_ready = False self._splash_timer = None @@ -367,11 +372,12 @@ def poll_metrics(self) -> None: # Processes ride on the snapshot now; collection is opt-in per # tick so a hidden table costs no process walk, and the live # filter regex is passed each iteration (no worker restart). + include_procs = self._show_processes snapshot = monitor.get_snapshot( - include_processes=self._show_processes, + include_processes=include_procs, process_filter=self._filter_regex, ) - self.post_message(MetricsUpdated(snapshot)) + self.post_message(MetricsUpdated(snapshot, include_procs)) finally: monitor.close() @@ -402,6 +408,8 @@ def on_metrics_updated(self, message: MetricsUpdated) -> None: def _paint_metrics(self, message: MetricsUpdated) -> None: self.query_one("#hardware-dash", HardwareDashboard).update_metrics(message) + if message.processes_included: + self._processes_pending = False self._last_processes = message.snapshot.processes self._last_cpu_watts = message.snapshot.cpu_watts self._last_gpu_watts = message.snapshot.gpu_watts @@ -463,6 +471,8 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No def action_toggle_processes(self) -> None: table = self.query_one("#process-table", DataTable) self._show_processes = not self._show_processes + if self._show_processes: + self._processes_pending = True table.display = self._show_processes self._refresh_process_table() # Re-evaluate check_action so the footer shows/hides `/ Filter` at once. @@ -561,6 +571,23 @@ def _refresh_process_table(self) -> None: cpu_watts = self._last_cpu_watts gpu_watts = self._last_gpu_watts sorted_procs = sort_processes(self._last_processes, self._sort_mode, limit) + if not sorted_procs: + # An empty table reads as a broken panel, so say why it is empty. + # The wait after `p` is up to two intervals (the in-flight sample + # was started with the walk off); while paused no sample is coming + # at all, so point at the key that resumes one. + if self._processes_pending: + note = ( + "paused — press space to resume" + if self._stop_polling.is_set() + else "collecting process samples…" + ) + else: + note = "no processes match filter" + table.add_row("", note, "", "", "", "", "") + table.border_subtitle = "" # no Σ to reconcile against yet + return + shown_pwr = 0.0 for proc in sorted_procs: # PWR is the L2-computed CPU+GPU time-share partition of package diff --git a/actop/tui/widgets.py b/actop/tui/widgets.py index f3369a2..1554ae9 100644 --- a/actop/tui/widgets.py +++ b/actop/tui/widgets.py @@ -491,8 +491,15 @@ def _render_block(self, width: int, height: int, color_mode: str): class MetricsUpdated(Message): """Posted by ActopApp when a new hardware snapshot is ready.""" - def __init__(self, snapshot: SystemSnapshot) -> None: + def __init__( + self, snapshot: SystemSnapshot, processes_included: bool = False + ) -> None: self.snapshot = snapshot # sole frame contract (RAM/swap/processes on it) + # Whether the worker asked for a process walk on this tick. `processes` + # is [] both when collection was off and when the filter matched + # nothing, so the flag is what lets the table tell "still collecting" + # apart from "nothing matched" instead of rendering a blank panel. + self.processes_included = processes_included super().__init__() diff --git a/pyproject.toml b/pyproject.toml index 0a1f2ec..0e1bdc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "actop" -version = "1.9.8" +version = "1.9.9" description = "Apple Silicon (M1–M4) power, GPU, ANE & memory-bandwidth monitor — sudoless TUI + Python API for profiling local LLM / MLX / CoreML inference" readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_per_process_power.py b/tests/test_per_process_power.py index 1cdf907..c95d83f 100644 --- a/tests/test_per_process_power.py +++ b/tests/test_per_process_power.py @@ -317,3 +317,54 @@ def test_process_table_renders_combined_cpu_gpu_pwr(): assert "18.1W" in subtitle # Σ shown assert "22.0W" in subtitle # pkg CPU+GPU = 2.0 + 20.0 + + +@pytest.mark.local # drives the real sampler and a real process walk +def test_process_table_says_it_is_collecting_until_the_first_walk_lands(): + # Toggling the table on with `p` cannot yield real rows on that frame: the + # sample already in flight was started with the process walk off, so rows + # are up to two intervals away (~4s at the default interval). The panel used + # to render empty for that whole window, which reads as a broken table. + # Driven against the live poll worker so the gap is the real one. + async def _run(): + from textual.widgets import DataTable + + app = ActopApp(build_parser().parse_args(["--interval", "1"])) + async with app.run_test(size=(160, 50)) as pilot: + table = app.query_one("#process-table", DataTable) + + def rows(): + return [ + [str(cell) for cell in table.get_row_at(i)] + for i in range(table.row_count) + ] + + for _ in range(60): # first real sample dismisses the splash + await pilot.pause() + if app.query_one("#main-section").display: + break + await asyncio.sleep(0.25) + + app.set_focus(None) # harness focuses the hidden filter Input + await pilot.press("p") + await pilot.pause() + pending = rows() + + collected = [] + for _ in range(40): # the walk starts on the next tick + await asyncio.sleep(0.25) + await pilot.pause() + if rows() and rows()[0][0].isdigit(): + collected = rows() + break + return pending, collected + + pending, collected = asyncio.run(_run()) + + assert len(pending) == 1, pending # a placeholder row, never a blank table + assert "collecting" in " ".join(pending[0]).lower(), pending + + # And it is transient: the first snapshot that carried a walk replaces it + # with real PID rows. + assert collected, "process rows never arrived" + assert all(row[0].isdigit() for row in collected), collected