Problem
In the TUI, several header/summary sparklines (most visibly the "GPU Metrics" box rendered by src/ui/gpu_sparkline_panel.rs) auto-scale their Y-axis to the per-frame data window's [min, max]. Because the braille sparkline only has 4 vertical levels, this auto-ranging makes the graphs effectively meaningless:
- Noise amplification — a steady temperature wiggling 45→47°C gets a range of
[45, 47] and fills the full height, looking like violent oscillation.
- Non-stationary baseline — the min/max are recomputed every frame as the 100-sample window (
AppConfig::HISTORY_MAX_ENTRIES = 100) slides, so the same 46°C sample lands at the bottom one frame and the top the next. Time-axis comparison is impossible.
- Absolute level lost — when the data is nearly constant,
max <= min triggers the "constant input" path in src/ui/braille.rs and every cell renders at the bottom row (⣀). A blazing 90°C and a cool 35°C look identical.
The GPU Metrics box also prints a min_max_badge (window min/max, gpu_sparkline_panel.rs) next to each row, and that number jitters frame to frame too, reinforcing the confusion.
Affected graphs (audited)
All sparkline_braille(...) call sites were reviewed:
| Location |
Metric |
Current range |
Status |
gpu_sparkline_panel.rs (GPU Metrics box) |
GPU Util / GPU Mem |
(0,100) fixed |
OK |
|
GPU Temp |
None (auto) |
BUG |
|
ANE |
None (auto) |
BUG |
|
Pkg Power |
None (auto) |
BUG |
local_header.rs (summary bar) |
CPU% / GPU% / RAM% |
(0,100) fixed |
OK |
|
Tmp (system/CPU temp) |
None (auto) |
BUG |
|
Pwr |
(0, window-max) dynamic |
BUG |
remote_sparkline_panel.rs (remote Live Statistics) |
Util / Mem (GPU·CPU) |
(0,100) fixed |
OK |
|
GPU Temp / CPU Temp |
None (auto) |
BUG |
activity_panel.rs (CPU cores) |
per-core bars |
100.0 fixed |
OK |
Note: CPU-utilization and memory sparklines are already pinned to (0,100) everywhere, so they are not subject to the moving-axis bug. The genuinely affected metrics are temperature, package power, and ANE.
Proposed solution
Replace per-window auto-ranging with fixed, domain-meaningful Y-axis ranges ("smart fixed range"):
- Temperature (system + GPU) →
(30, ceil). Floor 30°C (idle baseline). Ceiling = first reported GPU thermal threshold (temperature_threshold_slowdown → temperature_threshold_max_operating → temperature_threshold_shutdown), else fallback 100°C. CPU temperature has no threshold fields, so it uses the 100°C fallback. Bar height then means "how close to throttling".
- Package power (Pkg Power / summary Pwr) →
(0, ceil). Ceiling = enforced power limit from gpu.detail (power_limit_current → power_limit_max → power_limit_default, in W; available for NVIDIA/Gaudi), else nice_ceil(window_peak) with a 10W floor. Bar then means "fraction of power budget".
- ANE (Apple) →
(0, nice_ceil(max(window_peak, 8W))). No published cap; an 8W floor keeps an idle Neural Engine reading near-empty.
nice_ceil(v) rounds up to a pleasant 1/2/5 × 10ⁿ so small fluctuations don't shift the fallback ceiling.
- min-max badge (GPU Metrics box): show the fixed axis in use (e.g.
30-83, 0-350) instead of the jittery observed window min/max, so the badge becomes a stable scale legend. The "latest value" column already shows the current reading.
- CPU% / Mem% / per-core bars: no change.
Implementation scope
- New
src/ui/scale.rs with nice_ceil(), temp_range(gpu), power_range(gpu, history), ane_range(history), scale_badge(min,max), plus constants TEMP_FLOOR_C=30, TEMP_FALLBACK_CEIL_C=100, ANE_MIN_CEIL_W=8, POWER_MIN_CEIL_W=10. Register pub mod scale; in src/ui/mod.rs.
- Wire the helpers into the
None/dynamic ranges in gpu_sparkline_panel.rs, local_header.rs, and remote_sparkline_panel.rs. sparkline_braille itself already supports an explicit range and needs no change.
- Switch the GPU Metrics box badge to the fixed-axis form (replaces the existing
min_max_badge).
- Unit tests for the new helpers (axis stability under window shift,
nice_ceil rounding, threshold/power-limit fallbacks).
Acceptance criteria
Problem
In the TUI, several header/summary sparklines (most visibly the "GPU Metrics" box rendered by
src/ui/gpu_sparkline_panel.rs) auto-scale their Y-axis to the per-frame data window's[min, max]. Because the braille sparkline only has 4 vertical levels, this auto-ranging makes the graphs effectively meaningless:[45, 47]and fills the full height, looking like violent oscillation.AppConfig::HISTORY_MAX_ENTRIES = 100) slides, so the same 46°C sample lands at the bottom one frame and the top the next. Time-axis comparison is impossible.max <= mintriggers the "constant input" path insrc/ui/braille.rsand every cell renders at the bottom row (⣀). A blazing 90°C and a cool 35°C look identical.The GPU Metrics box also prints a
min_max_badge(window min/max,gpu_sparkline_panel.rs) next to each row, and that number jitters frame to frame too, reinforcing the confusion.Affected graphs (audited)
All
sparkline_braille(...)call sites were reviewed:gpu_sparkline_panel.rs(GPU Metrics box)(0,100)fixedNone(auto)None(auto)None(auto)local_header.rs(summary bar)(0,100)fixedNone(auto)(0, window-max)dynamicremote_sparkline_panel.rs(remote Live Statistics)(0,100)fixedNone(auto)activity_panel.rs(CPU cores)100.0fixedNote: CPU-utilization and memory sparklines are already pinned to
(0,100)everywhere, so they are not subject to the moving-axis bug. The genuinely affected metrics are temperature, package power, and ANE.Proposed solution
Replace per-window auto-ranging with fixed, domain-meaningful Y-axis ranges ("smart fixed range"):
(30, ceil). Floor 30°C (idle baseline). Ceiling = first reported GPU thermal threshold (temperature_threshold_slowdown→temperature_threshold_max_operating→temperature_threshold_shutdown), else fallback100°C. CPU temperature has no threshold fields, so it uses the100°Cfallback. Bar height then means "how close to throttling".(0, ceil). Ceiling = enforced power limit fromgpu.detail(power_limit_current→power_limit_max→power_limit_default, in W; available for NVIDIA/Gaudi), elsenice_ceil(window_peak)with a 10W floor. Bar then means "fraction of power budget".(0, nice_ceil(max(window_peak, 8W))). No published cap; an 8W floor keeps an idle Neural Engine reading near-empty.nice_ceil(v)rounds up to a pleasant1/2/5 × 10ⁿso small fluctuations don't shift the fallback ceiling.30-83,0-350) instead of the jittery observed window min/max, so the badge becomes a stable scale legend. The "latest value" column already shows the current reading.Implementation scope
src/ui/scale.rswithnice_ceil(),temp_range(gpu),power_range(gpu, history),ane_range(history),scale_badge(min,max), plus constantsTEMP_FLOOR_C=30,TEMP_FALLBACK_CEIL_C=100,ANE_MIN_CEIL_W=8,POWER_MIN_CEIL_W=10. Registerpub mod scale;insrc/ui/mod.rs.None/dynamic ranges ingpu_sparkline_panel.rs,local_header.rs, andremote_sparkline_panel.rs.sparkline_brailleitself already supports an explicit range and needs no change.min_max_badge).nice_ceilrounding, threshold/power-limit fallbacks).Acceptance criteria
nice_ceil/ constants.gpu_sparkline_panel.rs,local_header.rs,remote_sparkline_panel.rs) — not just standalone helpers — so the running TUI reflects the fix.cargo fmt --check,cargo clippy, andcargo testpass.