diff --git a/README.md b/README.md index 72ecb88..e869573 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ workspace tokens. Renders in the left sidebar, per workspace: +- GPU % (Apple Silicon, rootless — bundled `gpu-util` helper reads the IORegistry via public IOKit user-space APIs; no sudo, no powermetrics; `--` without the stat) - CPU % (100 − idle) - Memory — active + wired pages → GB (port of `~/dotfiles/tmux/memory.sh`) - Battery — Nerd Font glyph + percentage, charging-aware @@ -66,6 +67,7 @@ correct on your system. - Herdr ≥ 0.7.0 - macOS (v0.1 uses `pmset`, `vm_stat`, `df`). Linux reads land in v0.2. +- `gpu-util` rebuilds with `clang` when available; otherwise the bundled arm64 binary is used. - `jq` (for parsing `herdr … --json`) - A Nerd Font for the battery / wifi / clock glyphs (JetBrainsMono Nerd Font works, matching the existing Ghostty config) diff --git a/bin/gpu-util b/bin/gpu-util new file mode 100755 index 0000000..505cf87 Binary files /dev/null and b/bin/gpu-util differ diff --git a/bin/gpu-util.c b/bin/gpu-util.c new file mode 100644 index 0000000..ae83469 --- /dev/null +++ b/bin/gpu-util.c @@ -0,0 +1,74 @@ +/* + * gpu-util — rootless GPU utilization for Apple Silicon. + * + * Reads the GPU's "Device Utilization %" out of the IORegistry + * (PerformanceStatistics on the AGX accelerator service). Pure user-space + * IOKit access — no sudo, no powermetrics, no private framework linkage + * (the symbols used are all public IOKit/CoreFoundation APIs). + * + * Usage: gpu-util prints an integer percent (e.g. "3") + * prints "--" when no GPU/stat is found. + * gpu-util --raw same, but exits 1 when unavailable + * + * The chip-generation-specific class name (AGXAcceleratorG14X, G13, …) + * is deliberately NOT matched by name; the recursive property search finds + * whatever accelerator the current SoC exposes, so the helper survives + * future GPU generations without changes. + * + * Build (macOS, Xcode CLT): + * clang -O2 -framework IOKit -framework CoreFoundation \ + * -o gpu-util gpu-util.c + */ + +#include +#include +#include +#include + +/* The utilization value lives NESTED inside the accelerator's + * "PerformanceStatistics" dictionary, so the search must first locate + * that top-level property, then read the sub-key from the dict. */ +#define STATS_KEY CFSTR("PerformanceStatistics") +#define UTIL_KEY CFSTR("Device Utilization %") + +int main(int argc, char *argv[]) { + int raw = (argc > 1 && strcmp(argv[1], "--raw") == 0); + + io_registry_entry_t root = MACH_PORT_NULL; + root = IORegistryGetRootEntry(kIOMainPortDefault); + if (root == MACH_PORT_NULL) { + raw ? exit(1) : puts("--"); + return 0; + } + + /* Recursive search over the IOService plane; matching=NULL → any entry. + * Returns a retained CFNumber when found. */ + CFTypeRef stats = IORegistryEntrySearchCFProperty( + root, kIOServicePlane, STATS_KEY, NULL, kIORegistryIterateRecursively); + CFTypeRef value = NULL; + if (stats != NULL) { + if (CFGetTypeID(stats) == CFDictionaryGetTypeID()) { + value = CFDictionaryGetValue(stats, UTIL_KEY); + if (value != NULL) CFRetain(value); + } + CFRelease(stats); + } + + if (value == NULL) { + /* No discrete/integrated GPU stats exposed (e.g. headless VM) */ + raw ? exit(1) : puts("--"); + return 0; + } + + if (CFGetTypeID(value) == CFNumberGetTypeID()) { + int pct = -1; + if (CFNumberGetValue(value, kCFNumberIntType, &pct) && pct >= 0) { + printf("%d\n", pct); + CFRelease(value); + return 0; + } + } + CFRelease(value); + raw ? exit(1) : puts("--"); + return 0; +} diff --git a/config/sidebar.toml.snippet b/config/sidebar.toml.snippet index a141df9..2b801c9 100644 --- a/config/sidebar.toml.snippet +++ b/config/sidebar.toml.snippet @@ -14,6 +14,7 @@ # $sys_path — focused pane cwd (basename, 32-char truncated) # $sys_zoom — " zoom " when the focused pane is zoomed, else empty # $sys_prefix — prefix-active indicator (v0.1: always empty; v0.2) +# $sys_gpu — GPU usage 0-100% (rootless, Apple Silicon; "--" without GPU) # $sys_cpu — CPU usage 0-100% # $sys_mem — active+wired memory in GB # $sys_batt — battery glyph + percentage @@ -37,7 +38,7 @@ rows = [ ["state_icon", "workspace"], ["branch", "git_status"], ["$sys_path", "$sys_zoom", "$sys_prefix"], - ["$sys_cpu", "$sys_mem", "$sys_batt"], - ["$sys_net", "$sys_disk"], + ["$sys_gpu", "$sys_cpu", "$sys_mem"], + ["$sys_batt", "$sys_net", "$sys_disk"], ["$sys_date", "$sys_time"], ] \ No newline at end of file diff --git a/scripts/metrics.sh b/scripts/metrics.sh index 469ce8a..eba402a 100755 --- a/scripts/metrics.sh +++ b/scripts/metrics.sh @@ -79,6 +79,23 @@ sys_date() { date "+%a %b %d" } +# GPU utilization as 0-100% — rootless via the IORegistry. +# Uses the bundled gpu-util helper (pure IOKit user-space API, no sudo, +# no powermetrics). Searches for the accelerator's PerformanceStatistics +# property instead of matching a chip-specific class name, so it works +# across GPU generations. Falls back to "--" like every other collector. +sys_gpu() { + local pct + pct=$("$SYSMON_ROOT/bin/gpu-util" 2>/dev/null) + if [[ "$pct" =~ ^[0-9]+$ ]]; then + printf "%s%%" "$pct" + else + echo "--" + fi +} + +# Focused pane cwd, basename only + # Focused pane cwd, basename only, truncated to 32 chars with leading ellipsis. # Mirrors the tmux #{=/-32/...:#{b:pane_current_path}} status-left widget. # Best-effort: the `pane current` JSON shape is not fully documented; we try diff --git a/scripts/poll.sh b/scripts/poll.sh index 0093acd..5dc1e82 100755 --- a/scripts/poll.sh +++ b/scripts/poll.sh @@ -20,6 +20,7 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SYSMON_ROOT="$PLUGIN_ROOT" STATE_DIR="${HERDR_PLUGIN_STATE_DIR:-$PLUGIN_ROOT/.state}" mkdir -p "$STATE_DIR" @@ -41,16 +42,19 @@ log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >>"$LOGFILE"; } # NOTE: herdr CLI emits JSON by default; there is no --json flag. list_workspace_ids() { "$HERDR" workspace list 2>/dev/null | jq -r ' - (.result.workspaces // .workspaces // []) | .[].workspace_id // empty + # herdr >= 0.8: workspaces carry no workspace_id; the id is the workspace + # half of active_tab_id ("w3:t1" -> "w3"). + (.result.workspaces // .workspaces // []) + | .[] | (.active_tab_id // "" | split(":")[0]) // .label // empty ' 2>/dev/null } # Collect every metric once, then push the full token set to every workspace. push_all() { - local cpu mem batt net disk time date path zoom prefix + local cpu mem batt net disk time date path zoom prefix gpu cpu=$(sys_cpu); mem=$(sys_mem); batt=$(sys_batt); net=$(sys_net) disk=$(sys_disk); time=$(sys_time); date=$(sys_date); path=$(sys_path) - zoom=$(sys_zoom); prefix=$(sys_prefix) + zoom=$(sys_zoom); prefix=$(sys_prefix); gpu=$(sys_gpu) # Never-empty guards. The collectors already fall back, but a total command # failure (e.g. disk full, binary missing) can still yield an empty string; @@ -63,6 +67,7 @@ push_all() { [[ -z "$time" ]] && time="--" [[ -z "$date" ]] && date="--" [[ -z "$path" ]] && path="--" + [[ -z "$gpu" ]] && gpu="--" local wids wid wids=$(list_workspace_ids) @@ -73,6 +78,7 @@ push_all() { for wid in $wids; do "$HERDR" workspace report-metadata "$wid" --source "$SOURCE_ID" \ + --token "sys_gpu=$gpu" \ --token "sys_cpu=$cpu" \ --token "sys_mem=$mem" \ --token "sys_batt=$batt" \