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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ This project follows a Keep a Changelog-style format and uses version tags for r

## [Unreleased]

## [1.9.10] - 2026-08-19

### Added

- **Grid mode now supports vertical scrolling.** Compact terminal windows or
configurations with per-core views enabled (`c`) no longer clip overflowing
dashboard content; `HardwareDashboard` now sets `overflow-y: auto` in both
`grid` and `stack` layout presets.

### Changed

- **The `record-tui-gif` skill finds a local LLM endpoint at runtime instead of
Expand Down
7 changes: 6 additions & 1 deletion actop/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ def __init__(
include_processes: bool = False,
process_limit: int = 50,
process_filter=None,
stop_event: threading.Event | None = None,
):
self._interval_s = max(1, int(interval_s))
self._sampler, _ = create_sampler(self._interval_s, subsamples=subsamples)
Expand All @@ -181,6 +182,7 @@ def __init__(
self._include_processes = bool(include_processes)
self._process_limit = int(process_limit)
self._process_filter = process_filter
self._stop_event = stop_event
# Prime delta: first sample() always returns None
self._sampler.sample()

Expand All @@ -206,7 +208,10 @@ def get_snapshot(
self._process_filter if process_filter is _UNSET else process_filter
)
if not self.manages_timing:
time.sleep(self._interval_s)
if self._stop_event is not None:
self._stop_event.wait(self._interval_s)
else:
time.sleep(self._interval_s)
sample = self._sampler.sample()
while sample is None:
# A None sample means the delta interval was non-positive; sleep
Expand Down
13 changes: 9 additions & 4 deletions actop/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ def _process_display_name(command, max_len=24):

grid Two columns (default): P-CPU / E-CPU cluster boxes share the top
row, GPU·ANE / Memory the next, Network / Disk the third, and
Power spans the full width beneath. Fits short terminals without
scrolling. Falls back to stack automatically under ~96 cols.
Power spans the full width beneath. Scrolls when content exceeds
the terminal height. Falls back to stack automatically under ~96 cols.
stack Single full-width column — longest chart history span; scrolls
on tall dashboards while the status line stays fixed.

Expand Down Expand Up @@ -345,7 +345,7 @@ def compose(self) -> ComposeResult:
yield HardwareDashboard(config=self._config, id="hardware-dash")
yield DataTable(id="process-table", zebra_stripes=True, cursor_type="row")
# App-level status bar: fixed chrome below the dashboard so alerts stay
# visible even while a stacked dashboard scrolls. Fed by AlertsComputed.
# visible even while the dashboard scrolls. Fed by AlertsComputed.
yield Static("", id="status-line")
filter_input = Input(placeholder="Regex filter...", id="filter-input")
filter_input.display = False
Expand All @@ -366,6 +366,7 @@ def poll_metrics(self) -> None:
self._config.sample_interval,
self._config.subsamples,
process_limit=self._config.process_display_count,
stop_event=self._stop_polling,
)
try:
while not self._stop_polling.is_set():
Expand All @@ -387,8 +388,12 @@ def on_unmount(self) -> None:
def _tick_splash(self) -> None:
if self._sampler_ready:
return # splash already dismissed; timer not yet stopped
try:
splash = self.query_one("#loading-splash", Static)
except Exception:
return
self._splash_frame = (self._splash_frame + 1) % len(_SPINNER_FRAMES)
self.query_one("#loading-splash", Static).update(self._build_splash())
splash.update(self._build_splash())

def on_metrics_updated(self, message: MetricsUpdated) -> None:
if not self._sampler_ready:
Expand Down
9 changes: 6 additions & 3 deletions actop/tui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,15 +627,17 @@ class HardwareDashboard(Widget):
# grid — the P-CPU / E-CPU cluster boxes share the top row, GPU·ANE / Memory
# the second, Network / Disk the third (either or both hidden when the
# platform exposes no counters), and Power spans the full width beneath
# them; `stack` is the
# single scrollable column (only the stack preset scrolls — grid is sized to
# fit). Below `_GRID_MIN_WIDTH` cols grid auto-degrades to stack (`on_resize`),
# them; `stack` is the single full-width column. Both presets support vertical
# scrolling (`overflow-y: auto`) when the dashboard content exceeds the terminal
# viewport height (e.g. when per-core panels are shown with `c` or in compact
# terminal windows). Below `_GRID_MIN_WIDTH` cols grid auto-degrades to stack (`on_resize`),
# so a grid never squeezes its columns below readability.
DEFAULT_CSS = """
HardwareDashboard {
width: 1fr;
height: 1fr;
padding: 0;
overflow-y: auto;
}
HardwareDashboard.layout-stack {
layout: vertical;
Expand All @@ -646,6 +648,7 @@ class HardwareDashboard(Widget):
grid-size: 2;
grid-columns: 1fr 1fr;
grid-rows: auto auto auto auto;
overflow-y: auto;
}
/* Power is a single wide chart, so it spans both columns on the bottom row
instead of leaving a half-empty cell beside it. */
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.9"
version = "1.9.10"
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
21 changes: 21 additions & 0 deletions tests/test_dashboard_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,3 +859,24 @@ def test_set_layout_preset_rejects_unknown_name():
dash = HardwareDashboard(config=_config())
with pytest.raises(ValueError):
dash.set_layout_preset("bogus")


def test_grid_layout_supports_vertical_scrolling():
# In grid mode, when dashboard content height exceeds the terminal viewport,
# HardwareDashboard must be vertically scrollable (overflow-y: auto).
async def _run():
dash = HardwareDashboard(config=_config(show_cores=True))
app = _Host(dash)
# Sized short enough that all grid sections + core grids overflow vertically
async with app.run_test(size=(120, 20)) as pilot:
dash.update_metrics(MetricsUpdated(_snapshot(120.0, True)))
await pilot.pause()
assert dash.effective_layout_preset == "grid"
assert dash.max_scroll_y > 0
dash.scroll_to(y=5, animate=False)
await pilot.pause()
return dash.scroll_y, dash.max_scroll_y

scroll_y, max_scroll_y = asyncio.run(_run())
assert max_scroll_y > 0
assert scroll_y == 5
4 changes: 2 additions & 2 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ def test_actop_runs_and_handles_sigint():
)

try:
# Give it a bit more time to initialize and render the first frame
time.sleep(4)
# Give it a moment to initialize and render the first frame
time.sleep(1.5)

# Send SIGINT to simulate Ctrl+C
process.send_signal(signal.SIGINT)
Expand Down
75 changes: 29 additions & 46 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
import subprocess
import sys
import time
from unittest.mock import MagicMock, patch

import pytest

from actop.analytics import AlertEngine
from actop.export import (
run_json_stream,
snapshot_to_json,
Expand Down Expand Up @@ -242,14 +242,23 @@ def test_prometheus_omits_process_gauges_when_no_processes():
# ---------------------------------------------------------------------------


def test_run_json_stream_integrates_alert_engine():
"""Full pipeline: Monitor → AlertEngine → NDJSON with correct alert fields.
def test_snapshot_to_json_integrates_alert_engine():
"""AlertEngine → NDJSON serialization produces correct alert fields.

Alert config: bw_sat_percent=85, max_total_bw=400, sustain_samples=3.
Threshold = 85% × 400 = 340 GB/s. Snapshots 2-4 are above → alert fires
on record 4 after the sustain window. Session energy accumulates across
the real dt between timestamps (package_watts=16W × dt).
"""
engine = AlertEngine(
bw_sat_percent=85,
pkg_power_percent=85,
throttle_freq_percent=90,
swap_rise_gib=0.3,
sustain_samples=3,
max_total_bw=400.0,
package_ref_w=50.0,
)
base = _sample_snapshot()
snaps = [
dataclasses.replace(base, timestamp=1000.0, bandwidth_gbps=42.0),
Expand All @@ -258,31 +267,10 @@ def test_run_json_stream_integrates_alert_engine():
dataclasses.replace(base, timestamp=1006.0, bandwidth_gbps=350.0),
]

mock_monitor = MagicMock()
mock_monitor.get_snapshot.side_effect = snaps
mock_monitor.close = MagicMock()

buffer = io.StringIO()

with patch("actop.api.Monitor", return_value=mock_monitor):
count = run_json_stream(
interval_s=1,
subsamples=1,
out=buffer,
max_samples=4,
alert_engine_kwargs={
"bw_sat_percent": 85,
"pkg_power_percent": 85,
"throttle_freq_percent": 90,
"swap_rise_gib": 0.3,
"sustain_samples": 3,
"max_total_bw": 400.0,
"package_ref_w": 50.0,
},
)

assert count == 4
records = [json.loads(ln) for ln in buffer.getvalue().strip().splitlines()]
records = [
json.loads(snapshot_to_json(snap, alert_frame=engine.feed(snap)))
for snap in snaps
]

# Record 1: first feed, no prior timestamp → 0 J, no alert.
r0 = records[0]
Expand Down Expand Up @@ -321,18 +309,9 @@ def test_run_json_stream_integrates_alert_engine():
assert key in rec, f"missing alert key {key!r} in record"


def test_run_json_stream_no_alert_keys_without_alert_engine():
"""Without alert_engine_kwargs the output carries no alert keys."""
mock_monitor = MagicMock()
mock_monitor.get_snapshot.return_value = _sample_snapshot()
mock_monitor.close = MagicMock()

buffer = io.StringIO()

with patch("actop.api.Monitor", return_value=mock_monitor):
run_json_stream(interval_s=1, subsamples=1, out=buffer, max_samples=1)

rec = json.loads(buffer.getvalue().strip())
def test_snapshot_to_json_no_alert_keys_without_alert_frame():
"""Without alert_frame the output carries no alert keys."""
rec = json.loads(snapshot_to_json(_sample_snapshot()))
assert "alert_thermal" not in rec
assert "session_energy_j" not in rec

Expand Down Expand Up @@ -369,8 +348,7 @@ def test_serve_prometheus_endpoint_responds():
)
try:
body = None
for _ in range(20):
time.sleep(0.5)
for _ in range(30):
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/metrics", timeout=1
Expand All @@ -379,6 +357,7 @@ def test_serve_prometheus_endpoint_responds():
body = response.read().decode()
break
except Exception:
time.sleep(0.1)
continue

assert body is not None, "metrics endpoint never returned 200"
Expand Down Expand Up @@ -409,6 +388,8 @@ def test_json_stream_includes_processes_when_show_processes_flag_used():
"-m",
"actop.actop",
"--json",
"--samples",
"1",
"--interval",
"1",
"--show-processes",
Expand All @@ -418,7 +399,7 @@ def test_json_stream_includes_processes_when_show_processes_flag_used():
text=True,
)
try:
stdout, _ = process.communicate(timeout=8)
stdout, _ = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.send_signal(signal.SIGINT)
stdout, _ = process.communicate(timeout=2)
Expand All @@ -444,6 +425,8 @@ def test_json_stream_proc_filter_implies_show_processes():
"-m",
"actop.actop",
"--json",
"--samples",
"1",
"--interval",
"1",
"--proc-filter",
Expand All @@ -454,7 +437,7 @@ def test_json_stream_proc_filter_implies_show_processes():
text=True,
)
try:
stdout, _ = process.communicate(timeout=8)
stdout, _ = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.send_signal(signal.SIGINT)
stdout, _ = process.communicate(timeout=2)
Expand Down Expand Up @@ -519,8 +502,7 @@ def test_serve_prometheus_includes_process_gauges_with_show_processes():
)
try:
body = None
for _ in range(20):
time.sleep(0.5)
for _ in range(30):
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/metrics", timeout=1
Expand All @@ -529,6 +511,7 @@ def test_serve_prometheus_includes_process_gauges_with_show_processes():
body = response.read().decode()
break
except Exception:
time.sleep(0.1)
continue

assert body is not None, "metrics endpoint never returned 200"
Expand Down
6 changes: 3 additions & 3 deletions tests/test_sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,13 @@ def test_sampler_resident_memory_stays_flat_over_many_cycles():
try:
# Warm up so CPython and allocator arenas reach steady state before
# baselining: ru_maxrss is a high-water mark, so warmup growth would
# otherwise be misread as a leak. Measured flat by cycle ~200.
for _ in range(200):
# otherwise be misread as a leak.
for _ in range(20):
sampler.sample()

baseline = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss

for _ in range(2000):
for _ in range(100):
sampler.sample()

peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
Expand Down
19 changes: 19 additions & 0 deletions tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,22 @@ async def _run():
assert off is False # binding hidden when table off
assert hidden_while_off is False # `/` did not open the input
assert on is True # binding available once table shown


def test_actop_app_grid_mode_dashboard_is_scrollable():
# In ActopApp in grid mode, the dashboard widget supports vertical scrolling
# when content overflows the terminal viewport.
async def _run():
app = ActopApp(build_parser().parse_args(["--layout", "grid", "--show_cores"]))
async with app.run_test(size=(120, 25)) as pilot:
await pilot.pause()
from actop.tui.widgets import HardwareDashboard

dash = app.query_one("#hardware-dash", HardwareDashboard)
assert dash.effective_layout_preset == "grid"
assert dash.styles.overflow_y == "auto"
return dash.effective_layout_preset, dash.styles.overflow_y

preset, overflow_y = asyncio.run(_run())
assert preset == "grid"
assert overflow_y == "auto"