Flash’s status bar is a host-rendered surface fed by named plugin segments.
Plugins publish text and rich marker values through Context.status; the host
accepts only names declared by the plugin manifest, updates a plugin’s segment
set atomically, and coalesces notifications before rendering. Hovering never
runs plugin work: inline popup content arrives in the same status value, and a
live update re-hit-tests the stationary pointer and refreshes a hovered pager
in place. Focused pagers hold their content stable until reopening or Command-R.
Planned resident-plugin reloads preserve each last status segment for at most
10 seconds while replacement values arrive. Republishing replaces it immediately;
an explicit empty value clears it. Stop, error, removal, and configuration
replacement clear immediately. This avoids blinking during a normal hot reload
without keeping failed telemetry indefinitely. Development builds stage binaries
outside watched plugin directories and replace only changed code or signing
identity; rebuilding unchanged plugins must not trigger resident reloads.
Ordinary status updates render in place with only a 100 ms crossfade: only
#[cyc] content opts into the upward carousel transition, and a pooled layer
reused for an ordinary metric must clear that transition first. Carousels are a
host primitive: a plugin publishes a StatusCarousel (lines plus a cycle) and
Flash owns the rotation, exactly as it does for #{cycle:} script sources.
The local system-monitor suite is deliberately split by resource. Each plugin
owns summary and details; every summary carries its preview through
StatusValue::with_preview, while the standalone details segment supports custom
templates. Each also publishes a popup-free label: the metric alone, with no section
name, no colour, and no padding. The host trims a status value, so a leading
space would not survive. The template supplies the name, the colour, and the
width, for example #[label]CPU#[default] #{p-3:flash.plugin.cpu.label}.
CPU/MEM/DSK percentages use two digits plus % (p-3), capped at 99;
battery charge can reach 100%, and detailed reports retain the actual values.
NET is the aggregate download + upload on the default-route
interface (1.2M, 12K); p-4 reserves its four cells, in decimal
bytes per second. Counting the default route avoids double-counting VPN traffic.
The labels contain no links or inline popups, so surrounding template bindings
own the name, the colour, clicks, and hover. summary remains the plugin’s own
one-token line (name, metric, and in full mode the chart). Raw numeric segments carry the
same figures without markup. The maintained configuration shows details in PTY pagers;
no third-party monitoring application is required. All five accept [plugin.<id>] summary_mode = "compact" | "full",
default to compact, and warn before falling back from an invalid value.
| Plugin | Nominal fast path | Slower path | Additional surface |
|---|---|---|---|
cpu |
CPU ticks every second (host_processor_info, in-process) |
GPU metadata every 15 seconds (ioreg) |
:cpu [refresh] |
memory |
Memory composition every second (host_statistics64 + sysctl, in-process) |
— | :memory [refresh] |
disks |
I/O counters every three seconds (ioreg) |
Mounted volumes re-read on core:volumes.changed; free space every 30 seconds (df) |
:disks [refresh] |
network |
Default-interface traffic every second while a traffic segment is shown (NET_RT_IFLIST2 sysctl, in-process) |
Interface, route, address, and SSID discovery at start and on core:network.changed; no poll |
:network [refresh], network.addresses |
power |
Battery/power snapshot on core:power.changed (IOKit power-source notification); no poll |
Health collected during refreshes with a 30-second TTL; explicit refresh forces it |
:power [refresh] |
Every monitor retains 20 fast samples for its chart. The one-second samplers
read kernel counters through the SDK’s flash_plugin::sys module (the unsafe
FFI lives in the SDK, never in a plugin) instead of forking a CLI per sample;
disks runs ioreg every three seconds, reducing its scheduled subprocesses
by two-thirds compared with one-second polling; cpu uses it only for GPU
metadata and caches the logical CPU count. Every monitor samples on a host
cadence (ctx.interval), and a tick that lands while the previous sample still
runs is skipped rather than queued. CPU’s first sample brackets one period —
a host deadline it awaits (ctx.wait), not a sleep — so the initial publish
carries a real figure.
The monitors are status-bound, so the host runs them only while a surface shows
one of their segments — except once a command such as :cpu show has started one,
when it keeps running unobserved. Their cadences therefore also follow
core:status.observed: cancelled while none of the plugin’s segments is shown,
re-armed with an immediate sample as soon as one is. Unobserved, :cpu show,
:memory show or :disks show samples before it answers.
Details add context using the same snapshots and histories, without additional collection:
MaxCapacity is not an mAh fallback.Standard detail layouts target 50 terminal columns, the default
[popup] min_width of 480 points at the 13-point font with 10-point padding
and a one-point border. A longer external name, path or address widens the
popup up to max_width (750 points, 80 columns) and wraps past it. The pager
reserves one footer row. Taller content scrolls in the pager. Smaller widths
wrap more lines.
See popups for the presentation boundary.
Keep the ownership boundaries intact:
ctx.interval) instead of arming a
timer, at the priority the scheduling table gives: one clock drives every
monitor, so their wake-ups coalesce.system owns destructive and session-level system actions, not telemetry.caffeinate alone owns sleep-assertion lifecycle.answers provides timezone lookup.No plugin arms a timer or sleeps to schedule work. Every cadence, deadline and
wait is registered with the host clock at an explicit priority (see
runtime ownership): high for a value on screen that the
user watches change, normal for ordinary sampling and settles, low for
remote pulls, retries and backoffs. Cadences behind a segment register only
while a surface shows it.
| Plugin · registration | Cadence or deadline | Priority | Why |
|---|---|---|---|
cpu · CPU sample |
1 s while observed | high | A one-second figure ticks visibly |
cpu · first-sample bracket |
One 1 s wait per process | high | The first figure on screen waits on it |
cpu · GPU sample |
15 s while observed | normal | Nobody watches it tick |
memory · sample |
1 s while observed | high | A one-second figure ticks visibly |
network · traffic |
1 s while a traffic segment is shown | high | One-second rates tick visibly |
disks · I/O activity |
3 s while observed | normal | A tenth of a second is invisible at three seconds |
processes · top tables |
2 s while top_cpu/top_mem is shown |
high | A table someone has open |
processes · focused app |
10 s while focused_app_details is shown |
normal | A tenth of a second is invisible at ten seconds |
processes · focus settle |
300 ms after a focus change | high | The placeholder on screen waits on it |
tmux · inventory settle |
100 ms quiet, 1 s at most, after a control-mode change | high | It redraws the session and window segments |
tmux · window-title settle |
300 ms quiet, 10 s at most | normal | Catalog only |
tmux · socket retry |
5, 15, 60 s backoff | low | A retry nobody waits on |
tmux · observer reattach |
1, 5, 15, 60 s backoff | low | A retry nobody waits on |
tmux · remote hosts |
5 s, retried 15–60 s | low | A remote pull over SSH |
answers · ECB rates |
6 h, retried every 15 min | low | A remote pull |
feed · articles |
refresh_interval (300 s default) |
low | A remote pull |
browsers, kitty, windows · settles |
300 ms–1 s windows, AX bursts up to 10 s | normal | Catalogs nobody watches refresh |
browsers · Firefox tab selection beats |
120–250 ms inside one selection | high | The user waits on the switch |
caffeinate registers nothing: a timed assertion is bounded by
caffeinate -t itself and ends on the process’s exit event.
Alongside the styled labels, each monitor publishes plain values without markup, so templates, numeric format markers and widgets can scale, chart or compare them. They reuse the samples and discovery results above and add no collection, timer or subprocess. They travel in the same publish-if-changed frame as the styled segments, so an unchanged frame stays off the wire.
| Segment | Value | Example |
|---|---|---|
#{flash.plugin.cpu.percent} |
Total CPU, integer 0–100 | 23 |
#{flash.plugin.cpu.history} |
Retained CPU totals, integers, oldest first | 12 18 23 |
#{flash.plugin.cpu.load} |
One-minute load average, two decimals | 3.47 |
#{flash.plugin.cpu.uptime} |
Time since boot, sleep included, two units | 3d 4h |
#{flash.plugin.memory.percent} |
Used memory, integer 0–100 | 68 |
#{flash.plugin.memory.history} |
Retained memory percentages, oldest first | 67 68 68 |
#{flash.plugin.disks.percent} |
Startup-volume usage, integer 0–100 | 57 |
#{flash.plugin.disks.read_bps}, write_bps |
Aggregate disk rates, whole bytes/s | 1572864 |
#{flash.plugin.disks.read}, write |
The same rates in binary units | 1.5 MiB/s |
#{flash.plugin.network.down_bps}, up_bps |
Default-route rates, whole bytes/s | 48213 |
#{flash.plugin.network.down_history}, up_history |
Retained rates, whole bytes/s, oldest first | 0 1536 48213 |
#{flash.plugin.network.address} |
First IPv4 address of the default-route interface | 192.168.1.20 |
#{flash.plugin.power.percent} |
Battery charge, integer 0–100 | 73 |
#{flash.plugin.power.state} |
charging, discharging, charged or ac |
charging |
Histories hold the same 20 samples as the charts and are space-separated.
Percentages round to the nearest integer and reach 100; the 99 cap belongs to
the fixed-width labels. An empty value clears the segment, because unknown is
not zero: a desktop without a battery clears power.percent, and a rate
before its second sample or after its stale window clears with its history,
while an idle disk or link publishes 0. power.state prefers the battery’s
own reading; ac covers a desktop and a battery held on the adapter without
charging. network.address follows discovery (at start and on each
core:network.changed) and stays empty for an IPv6-only default route.
cpu.uptime is one CLOCK_MONOTONIC read per CPU sample through the nix crate, which network already uses for
getifaddrs; Darwin derives that clock from kern.boottime, so it counts
sleep, as uptime(1) does.
The processes plugin publishes conky’s ${top} tables as two status
segments, one row per process, busiest first:
| Segment | Rows ranked by | Value column |
|---|---|---|
#{flash.plugin.processes.top_cpu} |
CPU, as a share of one core averaged over the last sample period | 12.5% |
#{flash.plugin.processes.top_mem} |
Resident memory | 1.2 GiB |
Each row is a name column 15 cells wide (longer names end in …) and a
right-aligned value; ties rank by name, then pid, so equal figures never
shuffle rows. The value is a multi-line table, so it reads best in a desktop
widget or a named popup; the bar joins its lines.
[plugin.processes]
top_count = 5 # rows per table, an integer from 1 to 20
An invalid top_count logs a warning and uses 5. Sampling is scoped to
observation: the host reports which of the plugin’s segments a surface shows
(core:status.observed, see the protocol),
and the plugin registers its two-second sample with the host clock only while
top_cpu or top_mem is among them. A table that stops being shown is
cleared, so showing it again never reads stale figures. Rows come from
host.process_table, the plugin’s one process model, so no subprocess runs.
#{flash.plugin.processes.focused_app_details} follows the same rule. Focus
changes only record which app is focused until the segment is observed; while
it is, the plugin samples that app once a focus burst settles and every ten
seconds for its CPU figure, which has no change event. The !kill process
catalog has no cadence at all: it refreshes when an app launches or quits and
when the flashlight opens.
Each monitor performs an initial refresh and keeps its last-good state across transient collection failures. Publish only when the rendered value changes, and latch a repeated failure until a successful collection clears it. Valid hardware absence is data rather than failure—for example, a desktop without a battery publishes the battery-not-installed surface.
Disk and network rates and histories still expire after their bounded stale windows. Retaining last-good metadata must not leave an inactive collector looking active indefinitely.
An explicit refresh command must not wait behind a background sample. It
attempts the collector non-blockingly and returns the cached report when the
collector is occupied. disks, network, and power express this with
RefreshGate::try_run; CPU and memory use equivalent per-collector try-locks.
Background/event producers may wait for the gate when the eventual newest
snapshot matters.
Keep high-frequency measurement separate from discovery and health work.
Independent collectors that become due together—CPU/GPU, disk I/O/capacity,
and power/health—run concurrently so their timeouts do not stack.
Power events refresh the charge and source immediately while respecting the
health TTL, so a burst of notifications does not repeatedly spawn ioreg.
Externally sourced labels enter a rich status value only through
Markup::text, which doubles literal hashes; intentional #[...] markup uses
Markup::raw and is never escaped. The shared format/style compiler preserves escaped literal hashes across
expansion. Rendering, fitting, interactions, and terminal serialization consume
typed styled runs; do not reinterpret literal text as markup in a later pass. Variable and alias syntax inside a plugin-published value is literal
text, not template syntax.
The monitor suite stays helperless and deny-default. It may use unprivileged
macOS commands, IOKit metadata, getifaddrs, and narrow host capabilities, but
must not grow a privileged resident helper. SMC CPU/GPU temperatures, fan
control, CPU/GPU frequency, and S.M.A.R.T. health therefore remain out of
scope; battery temperature reported by the power APIs is ordinary health data.
network intentionally has no broad network capability. Route discovery
uses /usr/sbin/netstat -rn -f inet[6], traffic uses in-process interface counters,
and local addresses use getifaddrs. Do not replace route discovery with /sbin/route,
which requires a broad system-socket grant. SSID reads go through the narrow
wifi_info host capability: event-driven discovery reads passively, and only
the explicit :network refresh action may request Location authorization.
feed publishes summary and label. Neither prints a section name. The
template writes the name, its colour, the link on that name, and the hover:
[statusbar]
template = "#[align=left]#[popup=feed]#[link=https://example.com]#[label]aggr#[default]#[nolink]#[nopopup] #[popup=feed-article]#{flash.plugin.feed.label}#[nopopup]"
[popup.feed]
command = ["newsboat", "-u", "status/newsboat/urls", "-C", "status/newsboat/config"]
persistent = true
cwd = "."
[popup.feed-article]
command = ["newsboat-article", "$FLASH_STATUS_LINK"]
cwd = "."
Set [plugin.feed] url to an RSS feed URL; without one, the plugin makes no
network requests. refresh_interval defaults to 300 seconds and cycle_interval
to 30 seconds. The plugin publishes every article in the window as one
host-rotated carousel; Flash rotates the headline, keeps the visible line until
its scheduled rotation across refreshes, and slides the title and domain
through the full bar height. A name written in the template stays still,
because it is not part of the carousel. The refresh cadence, driven by the
host clock, is the plugin’s only timer: an article that leaves the window
drops out at the next refresh.
Other metrics update without this transition, including when pooled layers are reused.
Only items with a valid publication date within the rolling last 24 hours participate, newest first. Missing dates and future dates are excluded. A failed refresh still expires old items; a transient network or parse failure retains the remaining last-good items, while a successful empty feed clears the segment.
Only the linked title shrinks, ending in an ellipsis while reserving the domain
and outbound arrow. The feed stays before the notch or, without one, before
the actual centre component; its arrow and click target remain visible. RSS item links
open the feed’s article page; an Atom link rel="via" extension supplies the
original article link when present. For AGGR, this means the title opens the
archived snapshot and the arrow opens the publisher. Each summary line owns
its preview, so the title, domain, and arrow share one popup region.
label is the same rotating headlines with no outbound arrow and no inline
preview. Its title and domain form one link to the feed item rather than three
separate targets. A #[popup=…] around that segment is the hover, so the
configuration points it at any command it likes — a feed reader, a script,
anything. The plugin does not decide what hovering a headline shows. When that
command contains $FLASH_STATUS_LINK, Flash expands it to the item link as
the process starts. A template
link around the name addresses the feed itself: clicking the name opens the
feed, clicking the headline or its domain opens that item.
Hovering a summary title, domain, or arrow opens a terminal-rendered preview of its opening
lines from content:encoded, falling back to description. Paragraph breaks,
headings, lists, quotations, emphasis, and code retain their structure. The
excerpt is bounded to 12 logical lines and 900 visible characters; longer
articles end with an ellipsis. Right-click a linked span to pin the
preview; left-click preserves each link destination.
For AGGR, the feed’s article body is also the content of its Markdown export.
The plugin prepares the excerpt during the background feed refresh; hovering
opens an owned less process over that cached excerpt, without fetching or
starting another collector. The registry removes the pager and its private
snapshot on dismissal. External text is escaped before adding styles, and
truncation preserves complete style markers. URL marker values are escaped separately.
Run focused crate tests while iterating, then the repository-wide plugin gate:
CARGO_TARGET_DIR=build/plugin-target cargo test --manifest-path Plugins/<id>/Cargo.toml
./Scripts/test-plugins.sh --lane all
For host popup or layout changes, finish with ./Scripts/install.sh --dev and
manually verify a stationary hover while the segment updates.