From bcf89aacee0f51e82f4ff9d0b5db5c3b3813c16d Mon Sep 17 00:00:00 2001 From: Bin Le Date: Wed, 19 Aug 2026 22:36:26 -0400 Subject: [PATCH] Release v1.9.10: support vertical scrolling in grid layout --- CHANGELOG.md | 9 ++++ actop/api.py | 7 ++- actop/tui/app.py | 13 ++++-- actop/tui/widgets.py | 9 ++-- pyproject.toml | 2 +- tests/test_dashboard_metrics.py | 21 +++++++++ tests/test_e2e.py | 4 +- tests/test_export.py | 75 +++++++++++++-------------------- tests/test_sampler.py | 6 +-- tests/test_tui_app.py | 19 +++++++++ 10 files changed, 105 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b705243..538fbfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/actop/api.py b/actop/api.py index 7392a4e..f2cacff 100644 --- a/actop/api.py +++ b/actop/api.py @@ -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) @@ -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() @@ -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 diff --git a/actop/tui/app.py b/actop/tui/app.py index 0729ba4..27e53ed 100644 --- a/actop/tui/app.py +++ b/actop/tui/app.py @@ -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. @@ -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 @@ -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(): @@ -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: diff --git a/actop/tui/widgets.py b/actop/tui/widgets.py index 1554ae9..6969e64 100644 --- a/actop/tui/widgets.py +++ b/actop/tui/widgets.py @@ -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; @@ -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. */ diff --git a/pyproject.toml b/pyproject.toml index 0e1bdc1..494220f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_dashboard_metrics.py b/tests/test_dashboard_metrics.py index fc22649..c978864 100644 --- a/tests/test_dashboard_metrics.py +++ b/tests/test_dashboard_metrics.py @@ -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 diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 0df1915..b39eb97 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -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) diff --git a/tests/test_export.py b/tests/test_export.py index 8d4c695..9a1e8ae 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -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, @@ -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), @@ -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] @@ -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 @@ -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 @@ -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" @@ -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", @@ -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) @@ -444,6 +425,8 @@ def test_json_stream_proc_filter_implies_show_processes(): "-m", "actop.actop", "--json", + "--samples", + "1", "--interval", "1", "--proc-filter", @@ -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) @@ -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 @@ -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" diff --git a/tests/test_sampler.py b/tests/test_sampler.py index bcb1e89..faf77c5 100644 --- a/tests/test_sampler.py +++ b/tests/test_sampler.py @@ -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 diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 2ebf035..386287e 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -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"