From c4016f1e605e0f652a96ba15f2ef6cde9bb63be9 Mon Sep 17 00:00:00 2001 From: Bin Le Date: Thu, 13 Aug 2026 10:04:52 -0400 Subject: [PATCH 1/2] Explain the process table's wait instead of rendering it blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling the table on with `p` 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 none. The bordered panel rendered empty for that whole window and read as a broken table. The table now renders a placeholder row saying why it is empty: `collecting process samples…` until the first walk lands, `paused — press space to resume` when polling is paused (no sample is coming at all), and `no processes match filter` once collection is running but nothing matched — previously also a blank panel. MetricsUpdated carries a `processes_included` flag because `snapshot.processes == []` means both "collection was off" and "the filter matched nothing"; the flag is what separates the two. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 11 +++++++ actop/tui/app.py | 31 ++++++++++++++++++-- actop/tui/widgets.py | 9 +++++- tests/test_per_process_power.py | 51 +++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb040d1..5bd218a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ This project follows a Keep a Changelog-style format and uses version tags for r ## [Unreleased] +### 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/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 From b7450653e8ffbdecee8d873627bcfad0f28d3357 Mon Sep 17 00:00:00 2001 From: Bin Le Date: Thu, 13 Aug 2026 10:05:17 -0400 Subject: [PATCH 2/2] Release v1.9.9 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd218a..0ac5ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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 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" }