diff --git a/CHANGELOG.md b/CHANGELOG.md index 400fcbe..cb040d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ This project follows a Keep a Changelog-style format and uses version tags for r ## [Unreleased] +## [1.9.8] - 2026-08-13 + +**Skip 1.9.1 through 1.9.7 — install this one instead.** Those seven were cut in +rapid succession (six of them inside forty minutes) while the release pipeline +itself was being stabilized: each fixed the flow that had just shipped the one +before it, and none carried a user-visible change worth a release of its own. +1.9.8 supersedes all of them and is the first release in that line with a real +fix for users. `CLAUDE.md` now gates releases on user-visible change so this +does not recur; the intermediate tags stay published because PyPI artifacts +already point at them. + +### Fixed + +- **Narrow grid columns no longer clip the tail off metric rows.** At + `_GRID_MIN_WIDTH` (96 cols) each box gets ~44 content columns, and the longest + rows are wider than that: the P-CPU summary lost the `%` off + `avg 41% · max 41`, and every residency row lost `high37` outright (that one + overflowed up to ~110 cols). The rows are now width-adaptive instead of + hard-clipped — carried open since 1.5.0. + - Rolling context degrades in three tiers, each row choosing the widest that + fits its own measured width: `avg N% · max N%` → `⌀N ▲N%` → `▲N%` → + nothing. Applies to every labelled row (P-CPU/E-CPU, GPU, ANE, RAM, Mem BW, + the four I/O rates, CPU/GPU/Package power). The reading itself is never + shortened to make room for its annotation. + - Residency rows size their density bar to what the breakdown leaves (16 chars + down to 8, dropped below that), so the four percentages always survive. The + bar is budgeted against the widest the breakdown can ever be, so it does not + breathe a column every time a percentage crosses 10. + - The fit is against each row's high-water widths, not the current frame's, so + a row on a tier boundary holds its form instead of flipping between the + spelled-out and compact shapes as digits come and go (`avg 8%` → `avg 10%`, + `987MHz` → `1987MHz`). + - `?` help and `docs/SPEC-system.md` §6.3 document the ⌀/▲ compact form. + ## [1.9.7] - 2026-08-13 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 23081f3..f3110bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,10 +52,34 @@ This file is the single source of truth for repository guidelines, used by Claud **RULE — cutting a release REQUIRES a GitHub Release, not just a tag.** A release is cut with `scripts/tag_release.sh`, which pushes the git tag **and** creates the GitHub Release for it via `gh release create` (release notes pulled from the matching `CHANGELOG.md` section). A bare `git tag` + push is **not** a release: it never surfaces on the `/releases` page or as `Latest`, and leaves the Releases history silently behind the code. Never cut a release by hand-tagging alone — if the `gh release create` step fails, finish it manually (see playbooks below) before calling the release cut done. +### When to cut a release + +**A release needs something that changed for users.** Repo-internal churn — CI +workflows, the release scripts themselves, docs, the cover page, agent skills, +test-only edits — lands under `CHANGELOG.md` `[Unreleased]` and ships with the +next release that carries a user-visible change. It is never a release on its +own. + +**One release per change set, and version numbers are permanent.** PyPI burns a +version string on first upload: it can be yanked but never reused, so every +throwaway release permanently consumes a number and adds a row to `/releases` +that users have to reason about. On 2026-08-13, six releases (1.9.2–1.9.7) +landed inside forty minutes, every one of them a fix to the release pipeline +that had just shipped the previous one. That is the failure mode this rule +exists to stop: when the release flow itself is what is broken, fix it on a +branch and cut **one** release at the end. + +The only reason to re-tag the same day is a release that is **broken for users** +— install fails, import fails, the TUI crashes on launch. Anything less waits. + +If several unreleased changes have accumulated, a release PR rolls them into one +dated section; do not manufacture one section per merged PR. + ### Release steps ```bash -# 1. Bump version and changelog via a PR (never commit the bump to main) +# 1. Bump version and changelog via a RELEASE PR (never commit the bump to main). +# Only do this when "When to cut a release" above says it is warranted. git switch -c release-vX.Y.Z # edit pyproject.toml ([project].version) and CHANGELOG.md (move Unreleased -> new version + date) git commit -am "Release vX.Y.Z" && git push -u origin release-vX.Y.Z @@ -236,7 +260,7 @@ a mount point, not a fake); faking the data or the logic under test is not. ## Commit & Pull Request Guidelines - **Branch from `main`; PR strictly into `main`.** Every branch forks from `main` and targets `main`. **Never fork a feature branch off another feature branch** (no stacked PRs): if you need work that is still on an unmerged branch, wait for it to merge and re-branch from `main`. This holds especially for CI/CD and release changes — they land via a single PR into `main`, never a chained branch. -- **Bump the version in every PR.** Each PR updates `pyproject.toml` version + moves `CHANGELOG.md` `[Unreleased]` into a new dated section, in the same PR: **patch bump by default, minor only for a milestone PR** (major reserved for breaking API/CLI changes). Tagging (`scripts/tag_release.sh`) is a separate step after merge; publishing to PyPI is a third, manual step after that — see the Release Process section above. +- **A normal PR does NOT bump the version.** It adds its entry under `CHANGELOG.md` `[Unreleased]` and leaves `pyproject.toml` alone. Only a **release PR** moves `[Unreleased]` into a dated version section and bumps `[project].version` — **patch by default, minor for a milestone** (major reserved for breaking API/CLI changes) — and a release PR is the only kind followed by `scripts/tag_release.sh`. See **When to cut a release** in the Release Process section for when a release PR is warranted; publishing to PyPI is a third, manual step after the tag — see the Release Process section above. - Use concise, imperative commit subjects (as seen in history), e.g. `Add support for M1 Ultra` or `actop/utils.py: add bandwidth of M2`. - Keep commits scoped to one logical change. - Before every commit and before every push, always run: diff --git a/README.md b/README.md index 8974a4d..108e875 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ and thermals — plus a **Python API** (`Monitor` / `Profiler`, `to_pandas()`) s can instrument your *own* local LLM / MLX / CoreML inference and training runs with SoC-accurate power and energy context. -![actop live during a llama.cpp inference run: the single-column stack layout switching chart glyphs and revealing the process panel, with live E-CPU/P-CPU/GPU/ANE utilization, per-core frequency, memory bandwidth, power charts, and a watt-attributed PWR column on Apple Silicon](images/actop-demo.gif) +![actop live during a llama.cpp inference run: the two-column grid layout switching chart glyphs, revealing the per-core panels and the process table, and cycling themes, with live E-CPU/P-CPU/GPU/ANE utilization, per-core frequency, memory bandwidth, network and disk I/O, power charts, and a watt-attributed PWR column on Apple Silicon](images/actop-demo.gif) -*Above: actop live under a local LLM run — the `stack` layout with braille then block sparklines, then the process panel (`t`) with its watt-attributed `PWR` column. Below: the default two-column `grid` layout (`l` cycles between them).* +*Above: actop live under a local LLM run — the default `grid` layout with braille then block sparklines, the per-core panels (`c`), the process table (`p`) with its watt-attributed `PWR` column, and a pass through the themes (`t`). Below: the same grid layout as a still (`l` cycles to the single-column `stack`).* ![actop dashboard in the default two-column grid layout: E-CPU/P-CPU/GPU/ANE utilization, per-core frequency, memory bandwidth, and power charts on Apple Silicon](images/actop.png) diff --git a/actop/tui/app.py b/actop/tui/app.py index d8188da..85d5e0e 100644 --- a/actop/tui/app.py +++ b/actop/tui/app.py @@ -180,7 +180,9 @@ def _process_display_name(command, max_len=24): account for vs total package CPU watts (a partition of it). avg · max Rolling average (over the --avg window) and session peak, - shown next to each live reading. + shown next to each live reading. A column too narrow for the + spelled-out form compacts it to ⌀avg ▲peak, then to ▲peak + alone — the reading itself is never shortened. [b]Status line[/b] diff --git a/actop/tui/widgets.py b/actop/tui/widgets.py index 5671d0f..f3369a2 100644 --- a/actop/tui/widgets.py +++ b/actop/tui/widgets.py @@ -510,8 +510,56 @@ def __init__(self, status: str) -> None: super().__init__() +# Rolling-context suffix glyphs for the compact tiers: ⌀ = rolling average, +# ▲ = session peak. The spelled-out `avg N · max N` stays the widest tier (what +# any roomy terminal shows); the glyphs appear only once a column is too narrow +# for it. Documented in the `?` help overlay under "Metric labels". +_AVG_GLYPH = "⌀" +_MAX_GLYPH = "▲" + + +def _fit_suffix(measured: tuple[int, ...], tiers: tuple[str, ...], width: int) -> str: + """Widest rolling-context tier that fits beside the headline. + + A 96-col grid gives each box ~44 content columns — too few for + ` avg N% · max N%` next to a full `P-CPU 41% @3200MHz (52°C)` headline, + which used to cost the row its trailing characters (a clipped `%`, a + residency row losing `high37`). Degrade the context instead: the headline is + the reading, avg/max is its annotation, so the annotation gives up width + first and drops out entirely before the reading is ever touched. + + `measured` is `(head_width, *tier_widths)` — the widths the row is fitted + against, which are the high-water marks rather than this frame's actual + lengths (see `_stable_widths`); the returned tier is still rendered with the + live values. `width <= 0` means the row has no layout to measure yet — a + section that this very frame is revealing (Network / Disk start hidden). + Take the narrowest tier there: it cannot overflow whatever width the row + turns out to have, and the next sample replaces it with the real fit. + """ + if width <= 0: + return tiers[-1] + head_width = measured[0] + for tier, tier_width in zip(tiers, measured[1:]): + if head_width + tier_width <= width: + return tier + return "" + + _RESIDENCY_ORDER = ("idle", "low", "mid", "high") _RESIDENCY_GLYPHS = {"idle": "░", "low": "▒", "mid": "▓", "high": "█"} +# The residency bar shrinks from _RESIDENCY_BAR_WIDTH toward _RESIDENCY_BAR_MIN +# to fit its column, and is dropped below that (a 4-6 char density bar reads as +# noise, not a distribution). The four percentages are the data and the bar is +# only their picture, so the picture pays for the width — the breakdown is never +# truncated. +_RESIDENCY_BAR_WIDTH = 16 +_RESIDENCY_BAR_MIN = 8 +# The bar is sized against the widest the breakdown can ever get, not against +# this frame's: the four buckets sum to 100, so the worst case is four two-digit +# values (a 100 forces the other three to 0). Budgeting for it keeps the bar a +# fixed width per row width, instead of breathing a column or two every time a +# percentage crosses 10 and dragging the numbers sideways with it. +_RESIDENCY_BREAKDOWN_MAX = len("idle25 low25 mid25 high25") def _residency_bar_widths(percentages: dict, bar_width: int) -> dict: @@ -534,19 +582,34 @@ def _residency_bar_widths(percentages: dict, bar_width: int) -> dict: return floors -def _format_residency_bar(percentages: dict, bar_width: int = 16) -> str: +def _format_residency_bar(percentages: dict, bar_width: int) -> str: """Fixed-width proportional block-density bar for one cluster/domain.""" widths = _residency_bar_widths(percentages, bar_width) return "".join(_RESIDENCY_GLYPHS[name] * widths[name] for name in _RESIDENCY_ORDER) -def _format_residency_row(label: str, percentages: dict, bar_width: int = 16) -> str: - """`P-CPU [bar] idleN lowN midN highN` DVFS residency summary line.""" - bar = _format_residency_bar(percentages, bar_width) +def _format_residency_row(label: str, percentages: dict, width: int = 0) -> str: + """`P-CPU [bar] idleN lowN midN highN` DVFS residency summary line. + + `width` is the row's available columns (0 when it has not been laid out yet, + which takes the full bar). The bar gets whatever a worst-case breakdown + leaves behind and is dropped below `_RESIDENCY_BAR_MIN`, so the four + percentages survive a 44-column grid cell intact instead of losing `high37` + to a clip. + """ breakdown = " ".join( f"{name}{percentages.get(name, 0)}" for name in _RESIDENCY_ORDER ) - return f"{label:<6} [{bar}] {breakdown}" + prefix = f"{label:<6} " + bar_width = _RESIDENCY_BAR_WIDTH + if width > 0: + # 4 = the brackets around the bar plus the two spaces before breakdown. + room = width - len(prefix) - _RESIDENCY_BREAKDOWN_MAX - 4 + bar_width = min(bar_width, room) + if bar_width < _RESIDENCY_BAR_MIN: + return f"{prefix}{breakdown}" + bar = _format_residency_bar(percentages, bar_width) + return f"{prefix}[{bar}] {breakdown}" class HardwareDashboard(Widget): @@ -715,6 +778,11 @@ def __init__(self, config, **kwargs) -> None: package_ref_w=cfg.package_ref_w, ) + # High-water (head, *tier) widths per metric row, keyed by widget id — + # what the width-adaptive avg/max context is fitted against so a row + # holds its form instead of flipping on a digit (see _stable_widths). + self._row_widths: dict = {} + # Per-core history (dict: index -> deque) self._core_hist: dict = {} self._last_p_cores: list = [] @@ -1188,7 +1256,7 @@ def update_metrics(self, message: MetricsUpdated) -> None: pcpu, s.pcpu_freq_mhz, cpu_temp, - self._pct_stats_suffix(self._pcpu_hist), + self._pct_stats_tiers(self._pcpu_hist), ) self._update_cluster_summary_row( "#ecpu-summary-row", @@ -1196,14 +1264,14 @@ def update_metrics(self, message: MetricsUpdated) -> None: ecpu, s.ecpu_freq_mhz, cpu_temp, - self._pct_stats_suffix(self._ecpu_hist), + self._pct_stats_tiers(self._ecpu_hist), ) if cfg.show_residency: - self.query_one("#pcpu-residency-row", Static).update( - _format_residency_row("P-CPU", s.pcpu_residency_pct) + self._update_residency_row( + "#pcpu-residency-row", "P-CPU", s.pcpu_residency_pct ) - self.query_one("#ecpu-residency-row", Static).update( - _format_residency_row("E-CPU", s.ecpu_residency_pct) + self._update_residency_row( + "#ecpu-residency-row", "E-CPU", s.ecpu_residency_pct ) # "(drv)" instead of "@NMHz" when gpu_util_pct came from the driver # rather than IOReport residency: in that case the GPU DVFS table could @@ -1215,12 +1283,12 @@ def update_metrics(self, message: MetricsUpdated) -> None: gpu_label.append(" (drv)") else: gpu_label.append(f" @{s.gpu_freq_mhz}MHz") - gpu_label.append(f"{gpu_temp}{self._pct_stats_suffix(self._gpu_hist)}") - self.query_one("#gpu-label", Static).update(gpu_label) + gpu_label.append(gpu_temp) + self._update_stat_row( + "#gpu-label", gpu_label, self._pct_stats_tiers(self._gpu_hist) + ) if cfg.show_residency: - self.query_one("#gpu-residency-row", Static).update( - _format_residency_row("GPU", s.gpu_residency_pct) - ) + self._update_residency_row("#gpu-residency-row", "GPU", s.gpu_residency_pct) # Renderer/Tiler detail: hidden when the accelerator exposes no # statistics. Availability is effectively constant per session, so @@ -1235,10 +1303,10 @@ def update_metrics(self, message: MetricsUpdated) -> None: ) ane_label = Text("ANE ") ane_label.append(f"{ane_pct}%", style=self._util_color(ane_pct)) - ane_label.append( - f" ({s.ane_watts:.1f}W){self._pct_stats_suffix(self._ane_hist)}" + ane_label.append(f" ({s.ane_watts:.1f}W)") + self._update_stat_row( + "#ane-label", ane_label, self._pct_stats_tiers(self._ane_hist) ) - self.query_one("#ane-label", Static).update(ane_label) # The snapshot carries raw bytes; formatting into GiB is presentation, so # it happens here. GiB (not GB) because these are 2^30 divisions — the @@ -1252,12 +1320,15 @@ def update_metrics(self, message: MetricsUpdated) -> None: ram_label = f"RAM {used_gib}/{total_gib}GiB sw:{swap_used}/{swap_total}GiB" else: ram_label = f"RAM {used_gib}/{total_gib}GiB" - ram_label += self._pct_stats_suffix(self._ram_hist) - self.query_one("#ram-label", Static).update(ram_label) + self._update_stat_row( + "#ram-label", ram_label, self._pct_stats_tiers(self._ram_hist) + ) self._render_power_rows() - self.query_one("#pkgpwr-label", Static).update( - f"Package Power {s.package_watts:.2f}W{self._watt_stats_suffix(self._pkg_w_hist)}" + self._update_stat_row( + "#pkgpwr-label", + f"Package Power {s.package_watts:.2f}W", + self._watt_stats_tiers(self._pkg_w_hist), ) # Memory bandwidth: hide the row entirely when the platform exposes no @@ -1269,8 +1340,10 @@ def update_metrics(self, message: MetricsUpdated) -> None: bw_label.display = s.bandwidth_available bw_chart.display = s.bandwidth_available if s.bandwidth_available: - bw_label.update( - f"Mem BW {s.bandwidth_gbps:.1f} GB/s{self._gbps_stats_suffix(self._bw_gbps_hist)}" + self._update_stat_row( + "#bw-label", + f"Mem BW {s.bandwidth_gbps:.1f} GB/s", + self._gbps_stats_tiers(self._bw_gbps_hist), ) # Network I/O: hide the section when getifaddrs returns no usable @@ -1364,35 +1437,110 @@ def _avg_max(self, hist) -> tuple[float, float]: peak_vals = vals[-real_n:] return (sum(avg_vals) / len(avg_vals), max(peak_vals)) - def _pct_stats_suffix(self, hist) -> str: - """` avg N% · max N%` context string for a percent-valued history. + def _pct_stats_tiers(self, hist) -> tuple[str, ...]: + """Rolling-context tiers for a percent-valued history, widest first. The unit is appended because the headline reading often carries a different unit (MHz, GB, W), so a bare number would be ambiguous — or, - for the RAM row, read as GB instead of percent. + for the RAM row, read as GB instead of percent. Only the widest tier + spells `avg`/`max` out; the narrow ones fall back to the ⌀/▲ glyphs (see + `_fit_suffix`), and the peak outlives the average because it is the + figure a session is usually judged on. """ avg, mx = self._avg_max(hist) - return f" avg {avg:.0f}% · max {mx:.0f}%" + return ( + f" avg {avg:.0f}% · max {mx:.0f}%", + f" {_AVG_GLYPH}{avg:.0f} {_MAX_GLYPH}{mx:.0f}%", + f" {_MAX_GLYPH}{mx:.0f}%", + ) - def _watt_stats_suffix(self, hist) -> str: - """` avg N.NW · max N.NW` context string for a watt-valued history.""" + def _watt_stats_tiers(self, hist) -> tuple[str, ...]: + """Rolling-context tiers for a watt-valued history, widest first.""" avg, mx = self._avg_max(hist) - return f" avg {avg:.1f}W · max {mx:.1f}W" + return ( + f" avg {avg:.1f}W · max {mx:.1f}W", + f" {_AVG_GLYPH}{avg:.1f} {_MAX_GLYPH}{mx:.1f}W", + f" {_MAX_GLYPH}{mx:.1f}W", + ) - def _gbps_stats_suffix(self, hist) -> str: - """` avg N.N · max N.N GB/s` context string for a bandwidth history.""" + def _gbps_stats_tiers(self, hist) -> tuple[str, ...]: + """Rolling-context tiers for a bandwidth history, widest first.""" avg, mx = self._avg_max(hist) - return f" avg {avg:.1f} · max {mx:.1f} GB/s" + return ( + f" avg {avg:.1f} · max {mx:.1f} GB/s", + f" {_AVG_GLYPH}{avg:.1f} {_MAX_GLYPH}{mx:.1f} GB/s", + f" {_MAX_GLYPH}{mx:.1f} GB/s", + ) - def _bps_stats_suffix(self, hist) -> str: - """` avg N · max N` context string for a byte-rate history. + def _bps_stats_tiers(self, hist) -> tuple[str, ...]: + """Rolling-context tiers for a byte-rate history, widest first. - One direction per call: each direction now owns a labelled chart row, - so the suffix carries that direction's own rolling context (matching - every other stats suffix here) instead of a combined rx+tx figure. + One direction per call: each direction owns a labelled chart row, so the + suffix carries that direction's own rolling context (matching every + other stats suffix here) instead of a combined rx+tx figure. Both values + keep their unit even in the compact tiers — unlike the percent/watt/GB/s + rows, an average and a peak here can land in different prefixes (KB/s vs + MB/s), so a shared trailing unit would be a lie. """ avg, mx = self._avg_max(hist) - return f" avg {_format_bps(avg)} · max {_format_bps(mx)}" + return ( + f" avg {_format_bps(avg)} · max {_format_bps(mx)}", + f" {_AVG_GLYPH}{_format_bps(avg)} {_MAX_GLYPH}{_format_bps(mx)}", + f" {_MAX_GLYPH}{_format_bps(mx)}", + ) + + def _update_stat_row(self, widget_id: str, head, tiers: tuple[str, ...]) -> None: + """Render `head` plus the widest rolling context its width affords. + + Every metric label goes through here so a narrow grid column degrades + the avg/max annotation (`_fit_suffix`) rather than losing the tail of + the line to a hard clip. `head` is a plain str or a styled `Text`; the + trailing pad keeps the row's background uniform across its full width. + """ + widget = self.query_one(widget_id, Static) + avail = widget.size.width + is_text = isinstance(head, Text) + head_len = head.cell_len if is_text else len(head) + suffix = _fit_suffix( + self._stable_widths(widget_id, head_len, tiers), tiers, avail + ) + if is_text: + line = head.copy() + line.append(suffix) + if avail > 0: + line = line[:avail] + line.pad_right(max(0, avail - line.cell_len)) + else: + line = head + suffix + if avail > 0: + line = line[:avail].ljust(avail) + widget.update(line) + + def _stable_widths( + self, key: str, head_len: int, tiers: tuple[str, ...] + ) -> tuple[int, ...]: + """High-water `(head, *tiers)` widths for one row, for a steady fit. + + Fitting against this frame's exact lengths makes a row sitting on a tier + boundary flip shape every time a digit appears or leaves — `avg 8%` to + `avg 10%`, `987MHz` to `1987MHz` — which is far more distracting than the + changing digits themselves. Fit against the widest this row has rendered + instead, so the form it settles on holds until the data genuinely reaches + a wider shape or the terminal resizes. Widths only ratchet up, exactly + like the session peak the suffix already reports: a row that briefly hits + three digits keeps the room for them. + """ + widths = (head_len, *(len(tier) for tier in tiers)) + seen = self._row_widths.get(key) + if seen is not None: + widths = tuple(max(pair) for pair in zip(seen, widths)) + self._row_widths[key] = widths + return widths + + def _update_residency_row(self, widget_id: str, label: str, percentages) -> None: + """Render one DVFS residency row, bar sized to the row's own width.""" + widget = self.query_one(widget_id, Static) + widget.update(_format_residency_row(label, percentages, widget.size.width)) def _append_io_percents(self) -> None: """Append this frame's chart percents for the four I/O rate histories. @@ -1446,8 +1594,10 @@ def _update_io_section(self, section_id: str, available: bool, rows) -> None: if not available: return for label_id, prefix, rate_bps, native_hist in rows: - self.query_one(label_id, Static).update( - f"{prefix} {_format_bps(rate_bps)}{self._bps_stats_suffix(native_hist)}" + self._update_stat_row( + label_id, + f"{prefix} {_format_bps(rate_bps)}", + self._bps_stats_tiers(native_hist), ) def _update_cluster_summary_row( @@ -1457,20 +1607,16 @@ def _update_cluster_summary_row( util_pct: int, freq_mhz: int, cpu_temp: str, - stats_suffix: str = "", + tiers: tuple[str, ...], ) -> None: """Render one full-width cluster summary line.""" - widget = self.query_one(widget_id, Static) - avail = max(widget.size.width, 1) - line = Text(f"{label} ") + head = Text(f"{label} ") # The % wears the chart's color for that value (see _util_color) so the # readout and its tracer agree at a glance; the MHz/temp/avg/max context # stays muted plain text. - line.append(f"{util_pct:3d}%", style=self._util_color(util_pct)) - line.append(f" @{freq_mhz}MHz{cpu_temp}{stats_suffix}") - line = line[:avail] - line.pad_right(max(0, avail - line.cell_len)) - widget.update(line) + head.append(f"{util_pct:3d}%", style=self._util_color(util_pct)) + head.append(f" @{freq_mhz}MHz{cpu_temp}") + self._update_stat_row(widget_id, head, tiers) # Inline power spark bounds: keep the spark legible (>= 8 chars) but never # let a wide terminal turn a one-line row into a full chart (cap 24), the @@ -1507,12 +1653,17 @@ def _render_power_row( The spark fills the gap between the headline and the avg/max suffix, clamped to [_POWER_SPARK_MIN, _POWER_SPARK_MAX]; below the minimum the - row drops the spark and keeps the numbers. + row drops the spark and keeps the numbers. The suffix is fitted first + (`_fit_suffix`) so the spark competes for width against whatever + annotation the row can actually afford, not against the widest one. """ widget = self.query_one(widget_id, Static) - avail = max(widget.size.width, 1) + avail = widget.size.width head = f"{label} {watts:.2f}W" - suffix = self._watt_stats_suffix(watt_hist) + tiers = self._watt_stats_tiers(watt_hist) + suffix = _fit_suffix( + self._stable_widths(widget_id, len(head), tiers), tiers, avail + ) room = avail - len(head) - 1 - len(suffix) # -1 for the space after head spark_w = max(0, min(self._POWER_SPARK_MAX, room)) if spark_w >= self._POWER_SPARK_MIN: @@ -1522,7 +1673,7 @@ def _render_power_row( line = f"{head} {spark}{suffix}" else: line = f"{head}{suffix}" - widget.update(line[:avail].ljust(avail)) + widget.update(line[:avail].ljust(avail) if avail > 0 else line) def _format_core_entry( self, prefix: str, core, col_width: int, append_sample: bool = True diff --git a/cover/actop-demo.gif b/cover/actop-demo.gif index 9cd629c..3fd56bf 100644 Binary files a/cover/actop-demo.gif and b/cover/actop-demo.gif differ diff --git a/docs/SPEC-system.md b/docs/SPEC-system.md index 4d3d962..8556927 100644 --- a/docs/SPEC-system.md +++ b/docs/SPEC-system.md @@ -355,6 +355,24 @@ honoring `NO_COLOR`). Each headline carries rolling `avg N · max N` context (500-sample deques). Avg over `--avg` window, max is session peak. Every stat carries its unit. +That context is **width-adaptive** (`_fit_suffix`): a 96-col grid gives each box +~44 content columns, too few for `avg N% · max N%` beside a full +`P-CPU 41% @3200MHz (52°C)` headline. Each row independently picks the widest +of three tiers that fits — `avg N% · max N%` → `⌀N ▲N%` → `▲N%` → nothing — +so the annotation gives up width and the reading never does. Residency rows are +adaptive the same way: the bar shrinks from 16 chars toward 8 and is dropped +below that, because the four percentages are the data and the bar is only their +picture. A row whose width is not yet measurable (a Network/Disk section on the +frame that reveals it) takes the narrowest tier, then fits properly one sample +later. + +The fit is computed from **high-water widths** (`_stable_widths`, keyed by +widget id) rather than the current frame's lengths, and the residency bar is +budgeted against the widest possible breakdown — otherwise a row sitting on a +tier boundary would change shape every time a digit appeared or left +(`avg 8%` → `avg 10%`, `987MHz` → `1987MHz`). Widths only ratchet up, so a form +holds until the data reaches a genuinely wider shape or the terminal resizes. + Alert/throttle/energy analytics live in L2 (`analytics.AlertEngine`). The engine is constructed from threshold values (never `DashboardConfig`), `feed(snapshot)` returns an `AlertFrame`. `HardwareDashboard._compute_alerts` diff --git a/docs/cover.html b/docs/cover.html index 44995d8..0f4d52f 100644 --- a/docs/cover.html +++ b/docs/cover.html @@ -165,7 +165,7 @@
- v1.9.7 + v1.9.8

actop.

Watch your Apple Silicon Mac the way it actually works — sudoless, @@ -274,7 +274,7 @@

actop — Apple Chip top

diff --git a/images/actop-demo.gif b/images/actop-demo.gif index 9cd629c..3fd56bf 100644 Binary files a/images/actop-demo.gif and b/images/actop-demo.gif differ diff --git a/pyproject.toml b/pyproject.toml index c037ccc..0a1f2ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "actop" -version = "1.9.7" +version = "1.9.8" 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 3fbb1da..fc22649 100644 --- a/tests/test_dashboard_metrics.py +++ b/tests/test_dashboard_metrics.py @@ -12,6 +12,7 @@ """ import asyncio +import dataclasses import re import pytest @@ -621,18 +622,21 @@ def test_residency_row_leans_idle_at_rest(): assert "idle92" in state["ecpu_residency_row"] -def test_residency_bar_has_no_gaps_or_overflow_at_fixed_width(): +def test_residency_bar_has_no_gaps_or_overflow_at_full_width(): # Largest-remainder allocation must always fill the bar exactly: the - # glyph count inside the brackets must equal the configured bar width, - # even for percentages that don't divide evenly. + # glyph count inside the brackets must equal the allocated bar width, + # even for percentages that don't divide evenly. Two samples so the second + # renders against a settled layout — a row sizes its bar to the width it + # measured, and the first frame after mount still carries the pre-layout + # width, where the bar would legitimately be narrower. skewed = _snapshot( 0.0, False, gpu_residency_pct={"idle": 33, "low": 34, "mid": 17, "high": 16}, ) - state = asyncio.run(_drive([skewed])) + state = asyncio.run(_drive([skewed, skewed])) bar = re.search(r"\[(.*?)\]", state["gpu_residency_row"]).group(1) - assert len(bar) == 16 + assert len(bar) == 16 # full bar: an 80-col row affords it def test_residency_rows_hidden_when_show_residency_disabled(): @@ -787,6 +791,70 @@ async def _run(): assert wide == "grid" # recovers when width returns +def test_narrow_grid_columns_degrade_context_instead_of_clipping_rows(): + # At exactly _GRID_MIN_WIDTH (96 cols) each box gets ~44 content columns — + # narrower than `P-CPU 41% @3200MHz (52°C) avg 41% · max 41%` (45 cols) or + # the residency row's full bar plus breakdown (50 cols). Those rows used to + # lose their tail to a hard clip: a P-CPU headline missing its `%`, a + # residency row missing `high37` entirely. Every row must now end on a whole + # reading, and a wide terminal must still get the spelled-out avg/max form + # (the degrade is width-driven, not unconditional). + busy = dataclasses.replace( + _snapshot( + 120.5, + True, + pcpu_util_pct=41.0, + pcpu_freq_mhz=3200, + cpu_temp_c=52.0, + pcpu_residency_pct={"idle": 60, "low": 1, "mid": 2, "high": 37}, + net_rx_bps=12_000_000.0, + net_tx_bps=340_000.0, + net_available=True, + ), + swap_used_bytes=int(1.2 * 1024**3), + swap_total_bytes=4 * 1024**3, + ) + + async def _run(width): + dash = HardwareDashboard(config=_config()) + app = _Host(dash) + async with app.run_test(size=(width, 45)) as pilot: + # Two samples: the Network box is hidden until the first snapshot + # reveals it, so its rows only have a measurable width from the + # second frame on — the same one-sample settle a real session has. + for _ in range(2): + dash.update_metrics(MetricsUpdated(busy)) + await pilot.pause() + rows = {} + for row_id in ( + "#pcpu-summary-row", + "#pcpu-residency-row", + "#ram-label", + "#bw-label", + "#net-rx-label", + ): + widget = dash.query_one(row_id, Static) + rows[row_id] = (str(widget.render()), widget.size.width) + return dash.effective_layout_preset, rows + + preset, narrow = asyncio.run(_run(96)) + assert preset == "grid" # 96 is the grid floor, not a degrade to stack + for text, width in narrow.values(): + assert len(text.rstrip()) <= width # nothing overflows its column + # Each row still ends on a complete reading rather than a severed one. + assert narrow["#pcpu-summary-row"][0].rstrip().endswith("%") + assert "high37" in narrow["#pcpu-residency-row"][0] + assert narrow["#ram-label"][0].rstrip().endswith("%") + assert narrow["#bw-label"][0].rstrip().endswith("GB/s") + assert narrow["#net-rx-label"][0].rstrip().endswith("B/s") + # The swap figures are part of the headline and are never traded away. + assert "sw:1.2/4.0GiB" in narrow["#ram-label"][0] + + _, wide = asyncio.run(_run(160)) + assert "avg 41% · max 41%" in wide["#pcpu-summary-row"][0] + assert "avg 120.5 · max 120.5 GB/s" in wide["#bw-label"][0] + + def test_set_layout_preset_rejects_unknown_name(): dash = HardwareDashboard(config=_config()) with pytest.raises(ValueError):