Skip to content

Add live per-app power to the Today view#4

Merged
EClinick merged 7 commits into
masterfrom
live-power
Jul 11, 2026
Merged

Add live per-app power to the Today view#4
EClinick merged 7 commits into
masterfrom
live-power

Conversation

@EClinick

Copy link
Copy Markdown
Owner

What

The popover's Today range becomes a hybrid view: a Drawing power now section with live per-app watts (sampled every 2 s while the popover is open, values in green with today's Wh as subtext), above an Earlier today section with the usual watt-hour ranking. The Stats window's Today gets the same two sections with an extra live-watts column. Ranges are Today / 3 Days / Week / All ("All Time" shortened to fit).

Why

macOS meters per-app energy continuously, but Juice could only show history - the "Drawing 12.4 W" headline had no per-app breakdown. This answers "what is eating my battery right now" in the same place users already look.

How

  • The root helper reads cumulative per-coalition energy counters (CPU + GPU + ANE, nanojoules) via the coalition_info resource-usage syscall - the same accounting Activity Monitor uses. powerlog intervals land minutes late, so they cannot serve a live view; per-process rusage billed-energy was tried first and reads zero for self-performed work.
  • New XPC protocol v3 method ships raw cumulative counters; the app differences consecutive snapshots into watts (half-life EMA, rank hysteresis, 30 s idle grace so rows do not bounce between sections).
  • Coalitions roll up to their outermost .app bundle, so browser/Electron helper processes attribute to the parent app; unattributable processes go to a labeled system bucket.
  • Honesty: on battery, a footer reconciles app watts against actual battery draw ("Apps X W - System & display Y W") instead of pretending per-app counters sum to it. Old helpers degrade to plain history with a caption. On macOS 14 the kernel struct predates the GPU/ANE fields, which safely read zero (CPU-only).

Verified

  • 144 tests pass (make test), including new unit suites for the power model (delta math, counter resets, coalition reappearance, hysteresis, idle fold, .app rollup) and the live/today merger (threshold, grace period, ordering).
  • Counter source verified empirically: a 2-core CPU spin measured 7.98 W via the coalition counters, cross-checked within 2% against task_info's task_energy for the same load.
  • End-to-end on a real machine with the dev helper installed: live watts attribute correctly (GUI-launched test app at 5.5 W under its own name), popover and Stats verified visually.

The popover's Today range is now a hybrid: apps currently drawing power
show live watts (sampled every 2 s while the popover is open), and the
rest of today's history shows accumulated watt-hours below. The Stats
window gets the same treatment with an extra live-watts column.

Live power comes from per-coalition cumulative energy counters (CPU,
GPU, and ANE, in nanojoules) read by the root helper through the
coalition_info resource-usage syscall - the same accounting Activity
Monitor uses. The helper ships raw counters over a new protocol v3 XPC
method; the app differences consecutive snapshots into watts, smooths
them with a half-life EMA, and keeps row order stable with rank
hysteresis plus a 30 s idle grace period. Coalitions roll up to their
outermost .app bundle so browser and Electron helpers attribute to the
parent app; unattributable processes land in a labeled system bucket
rather than being spread across apps.

On battery, an attribution footer reconciles per-app watts against the
battery draw instead of pretending they sum. Older helpers degrade to
plain history with a caption.
Copilot AI review requested due to automatic review settings July 11, 2026 20:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

vercel Bot commented Jul 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
website Ignored Ignored Preview Jul 11, 2026 9:08pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab28188701

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let byteCount = Int32(pids.count) * Int32(MemoryLayout<Int32>.size)
let written = proc_listallpids(&pids, byteCount)
guard written > 0 else { return [] }
let count = Int(written) / MemoryLayout<Int32>.size

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop dropping most PIDs from live snapshots

proc_listallpids already returns a count of PIDs, not a byte count, so dividing the second return value by MemoryLayout<Int32>.size keeps only the first quarter of the process list. On a typical machine with hundreds of processes, any app whose PID lands after that truncated prefix is never grouped into a coalition, so the new live per-app power view will silently miss active apps and underreport watts.

Useful? React with 👍 / 👎.

return errno != 0 ? errno : -1;
}
out->coalitionID = info.coalition_id[JUICE_COALITION_TYPE_RESOURCE];
out->isLeader = (info.coalition_role[JUICE_COALITION_TYPE_RESOURCE] == JUICE_COALITION_TASKROLE_LEADER) ? 1 : 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not read coalition roles from reserved fields

The PROC_PIDCOALITIONINFO result exposes coalition IDs plus reserved fields; it does not include per-task coalition roles. Reading info.coalition_role[0] therefore reads zeroed/reserved data, so membership.isLeader is effectively always false and LiveEnergyReader falls back to the lowest PID in each coalition. For multi-process coalitions where the leader is not the lowest PID, live attribution can pick the wrong executable path and roll watts up to the wrong app or the system bucket.

Useful? React with 👍 / 👎.

EClinick added 6 commits July 11, 2026 13:59
proc_listallpids returns a PID count from both the sizing and the fill
call (unlike proc_listpids, which returns bytes). Dividing the fill
result by the element size scanned only a quarter of the process table,
so most coalitions were represented by whichever of their PIDs happened
to land in that first quarter - or missed entirely.

Resource coalitions also have no leader role in the kernel, so the
reader now picks the lowest PID as the coalition's representative
outright.
The struct is 40 bytes: one coalition id per type (resource, jetsam)
plus three reserved words. The previous oversized declaration read its
'role' field from zeroed padding, so the leader-role check could never
fire and the shim's leader flag was meaningless. Drop it.
The live model's idle fold hid sub-threshold apps from the reading, so
the hybrid view's 30 s grace-period holdovers froze at their last
visible wattage. The controller now runs the model with no idle fold -
the merger applies the display threshold itself - and the model prunes
apps that have decayed below a tenth of a milliwatt so a long session
with app churn cannot grow state without bound.

Also skip status updates from an already-cancelled sampling loop so an
in-flight failure cannot overwrite the state a stop() just reset.
Only the earlier section was capped, so a machine with many apps above
the active threshold could render arbitrarily many live rows and push
the popover off screen again. Active rows now spend the same 8-row
budget first, and overflow from either section lands in the fold row.
Active-row overflow was combined with history overflow into a single
fold row under Earlier Today, miscategorizing currently-active apps as
history (and summing their watt-hours, not their watts). Each section
now folds its own overflow: the live section in watts, the history
section in watt-hours, still within the 8-row budget.
Metadata was restored after the prune removed it, so an app that decayed
below the prune floor and then exited left its metadata behind forever.
Refreshing before the smoothing loop lets the prune reclaim both maps
together.
@EClinick
EClinick merged commit 3ff9d8e into master Jul 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants