Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions actop/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion actop/tui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
51 changes: 51 additions & 0 deletions tests/test_per_process_power.py
Original file line number Diff line number Diff line change
Expand Up @@ -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