A fast, native tmux statusline written in Rust — powerline-style segments for pane, window, host, and system info, with zero required configuration.
- Powerline-style segments (pane id, hostname, window list, cwd, cpu, memory, load average, date/time) rendered as a single static binary — no shell scripts, no external statusline framework.
- Zero-config: sensible defaults out of the box.
- Optional TOML config to reorder widgets or tweak per-widget options.
- Built-in
cpuandmemorywidgets (in the default right layout) showing usage percentage, human-readable sizes, a Unicode gauge bar ({bar}), and an optional history sparkline ({spark}). - Opt-in
throughputwidget showing network download/upload rates (/proc/net/dev, Linux). - Opt-in
lan_ipandtailscale_ipwidgets that show the machine's LAN and Tailscale IPv4 addresses. - Opt-in
batterywidget showing charge percentage, state, and a level-bucketed Nerd-Font icon (Linux + macOS). - Opt-in
gitwidget showing the current branch (or short SHA when detached), a dirty marker, and ahead/behind/staged/unstaged counts, read via agit statusshell-out. - Opt-in
diskwidget showing filesystem usage (used/total/available, a percentage, and a gauge bar) for a configured mount, read viastatvfs(2). - Opt-in
uptimewidget (humanized, e.g.3d 4h) andmedianow-playing widget (artist/title/status, viaplayerctl). - Click-to-toggle widget alt views: give a widget an
alt_formatand left-clicking it in the status line swaps it to that view (e.g. a compactcpureading toggling to one with a gauge bar). You can also bind a specific mouse button (left/middle/right) to open a URL or run a command. - Per-widget color overrides (
fg/bg) to pin a widget's colors. - Run the same widget more than once with different options via
[instances.<name>]— e.g. two clocks in different timezones, or gauges for more than one disk mount. - Manage your layout without hand-editing TOML:
rustline widget list|enable|disable|move, orrustline widget editfor a full-screen interactive editor with a live preview — bound toprefix + Win tmux out of the box. See Widget manager below. - Optional persistent daemon (
rustline daemon run) that keeps your config, theme, widget registry, and WASM plugins warm across tmux refreshes; renders fall back to the normal in-process path automatically whenever it's not running. - Browse a curated plugin index (
rustline plugin search) and install third-party WASM plugins straight from GitHub (rustline plugin install owner/repo), then grant only the capabilities they need — including, if you choose to, running a local command (allowed_commands). A plugin's recorded checksum is verified every time it loads, not just recorded at install time — this catches accidental swaps and corrupt/stale files, though it's not a privilege boundary or a pin against a compromised upstream (see Plugins below for the exact scope). - Seven built-in themes (a
defaultplus six multi-accent, truecolor curated themes) selectable viarustline theme use, browsable interactively withrustline theme pick, plus atheme newscaffolder for tweaking your own — see Themes below. - Semantic colors (
success/info/warning/error) reach both built-in widgets and WASM plugins;cpu,memory,battery,loadavg, anddiskturn into an alert badge when a configurable threshold is crossed. - Instant refresh on pane/window switch via tmux hooks (no waiting for the
next
status-intervaltick). rustline doctorchecks your setup (tmux version, mouse mode, a truecolor terminal,rustlineon$PATH, the managed tmux-conf block) and reports pass/warn/fail;rustline completions <bash|zsh|fish>prints a shell-completion script.
Each tagged release publishes prebuilt tarballs for three targets on GitHub:
x86_64-unknown-linux-gnux86_64-unknown-linux-musl(static; no glibc dependency)aarch64-apple-darwin(Apple Silicon)
There is no Intel Mac (x86_64-apple-darwin) build: GitHub's macos-13 runner
label no longer picks up jobs, and an Apple Silicon binary will not run on an
Intel Mac. On an Intel Mac, build from source instead.
Each rustline-<version>-<target>.tar.gz contains the rustline binary,
shell-completion scripts under completions/ (rustline.bash, _rustline,
rustline.fish), README.md, and LICENSE. A SHA256SUMS file alongside the
tarballs lets you verify your download before extracting it:
# From the release's asset directory, with both files downloaded:
sha256sum -c --ignore-missing SHA256SUMS
# rustline-v0.1.0-x86_64-unknown-linux-gnu.tar.gz: OK
tar xzf rustline-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
cp rustline-v0.1.0-x86_64-unknown-linux-gnu/rustline ~/.local/bin/rustlineEvery release is unconditionally marked as a GitHub pre-release — the release workflow sets this on every tag it publishes, with no version check (there is no "graduate to a stable release" switch yet).
cargo build --releaseThis produces target/release/rustline. Copy or symlink it onto your PATH,
e.g.:
cp target/release/rustline ~/.local/bin/rustlineRun the onboarding wizard — it asks a few questions (theme, one- or two-line
status, mouse/click-to-toggle, which widgets, clock style, refresh rate), then
writes ~/.config/rustline/config.toml and adds a managed block to
~/.tmux.conf (backed up first):
rustline init
tmux source-file ~/.tmux.confThe wizard wires rustline render into status-left / status-right and the
window list — in two-line mode the window list renders in a single batched
rustline render windows --session=<s> call instead of one process per window
(one-line mode still renders each window separately) — sets status-interval
(1s or 5s, your choice), adds
after-select-pane / after-select-window hooks that call refresh-client -S
so the bar updates immediately when you switch panes or windows, and — if you
opt in — binds a left click on any clickable widget to toggle its alt_format
(see Click-to-toggle widget views below) and
turns on set -g mouse on for you. It also binds prefix + W to open the
interactive widget manager in a tmux popup — see Widget
manager below.
rustline init --defaults— non-interactive; recommended settings.rustline init --print— just print the raw one-line tmux block to stdout and write nothing (the pre-wizard behavior, handy for scripting:rustline init --print >> ~/.tmux.conf).rustline init --dry-run— preview the config.toml and tmux block a real run would write (with a line diff against any existing file), without touching disk.rustline init --uninstall— remove the managed tmux block from~/.tmux.conf(backing it up first) and print the reload command; never touchesconfig.toml. Combine with--dry-runto preview the removal instead of performing it.rustline init --binary <path>— override the binary path baked into the tmux block (default: the running binary's own resolved absolute path).
The tmux block is wrapped in # >>> rustline >>> / # <<< rustline <<<
markers, so re-running rustline init replaces that region instead of
appending a duplicate; your edits outside the markers are preserved. The
generated config.toml is merged non-destructively too: [theme].base is
always set to your pick, but existing [layout]/[widgets.*] sections you've
already customized are left alone. Note the tmux block calls rustline by its
resolved absolute path rather than a bare name, since the tmux server's own
shell may not share your interactive shell's $PATH — run rustline doctor
if the bar ever comes up empty.
Re-running rustline init later (say, to turn on mouse mode or switch
themes) seeds every question's shown default from what you chose last time
instead of resetting to the recommended answers, so you can hold Enter
through the questions you don't want to change. Your theme, clock style, and
which optional widgets you use come from your existing config.toml; the
one-/two-line, mouse, and refresh-interval answers are tmux settings that
config never stores, so those are read back out of the managed block in your
~/.tmux.conf. Then, before writing anything, it shows a summary of what you
answered plus a diff of both files against their current contents and asks
you to confirm.
Font requirement: the powerline separators are drawn with Powerline glyphs (U+E0B0–U+E0B3). Use a Nerd Font or another powerline-patched font in your terminal, or the separators will show as boxes/blanks.
tmux requirement: click-to-toggle needs tmux ≥ 3.1 (status-line click ranges) and
set -g mouse onin your tmux config — the wizard's mouse question can turn that on for you, or you can set it yourself. Theprefix + Wwidget-manager popup needs tmux ≥ 3.2 (display-popup); the status line itself still only needs ≥ 3.1.rustline doctorchecks both and warns (never fails) if either is missing.
With no config file, rustline renders:
- Left: pane id · hostname — e.g.
0:0.0·myhost - Center: window list, active window emphasized — e.g.
0* zsh 1 vim - Right: current directory (
$HOMEabbreviated to~) · cpu · memory · load average · date/time — e.g.~/src/rustline· 37%· 6.2G/16G·0.31 0.44 0.42·Mon < 2026-07-20 < 19:04
Config is optional TOML at ~/.config/rustline/config.toml (or
$XDG_CONFIG_HOME/rustline/config.toml when set). A missing or invalid file
just falls back to the defaults above — rustline never fails to render
because of a bad config.
Widget names available for the layout arrays are: pane_id / hostname
(see Hostname and pane ID widgets below),
windows, cwd (see Cwd widget below), cpu, memory (see
CPU and memory widgets below), loadavg,
datetime, and the opt-in lan_ip / tailscale_ip (see IP address
widgets below), battery (see Battery
widget below), git (see Git widget
below), disk (see Disk widget below), uptime / media
(see Uptime and media widgets below), and
throughput (see Throughput widget below). Most of
these can also appear more than once in your layout under a name of your
choosing — see Multiple widget instances below.
Example — reorder the right region and change the clock format:
[layout]
right = ["datetime", "cwd"]
[widgets.datetime]
format = "%H:%M"
# timezone = "America/New_York" # optional IANA zone; default renders local timedatetime also takes an optional timezone (an IANA zone name, e.g.
"Europe/Berlin") that renders the clock in that zone instead of local time;
an unrecognized name is logged and falls back to local time rather than
erroring.
Run rustline print-config to print the fully-resolved effective
configuration (your overrides layered onto the defaults) as TOML.
rustline config path prints the resolved config file's path; rustline config edit opens it in $EDITOR (creating it from the starter template
first if it doesn't exist); rustline config validate strictly parses it
and reports any error with its location, unlike Config::load's silent
fallback to defaults. A global --config <path> flag points any subcommand
at a different config file.
Edit which widgets are enabled and where they sit without hand-editing
[layout]:
rustline widget list # every widget + where it sits
rustline widget list --json # same, as JSON, for scripting
rustline widget enable git --region right # add git to the right region
rustline widget disable loadavg # remove a widget
rustline widget move git --region left # relocate a placed widget
rustline widget edit # interactive full-screen editorenable/move insert at the end of the target region by default; pass
--index N to place it elsewhere. An edit that would leave the layout
unchanged, target an already-placed widget, or name something unknown is
refused with an explanation and writes nothing.
rustline widget edit opens a full-screen ratatui editor (needs a
terminal): four columns — LEFT, CENTER, RIGHT, and AVAILABLE (everything not
currently placed) — navigated with arrow keys or vim keys (h/j/k/l).
space/Enter moves the selected widget between AVAILABLE and RIGHT (the
only region AVAILABLE placement appends into); J/K reorders it within
its current region; H/L (shifted, mirroring J/K) move a placed
widget directly to the other editable region (LEFT ↔ RIGHT — this is how
you get a widget into LEFT). A live preview strip along
the bottom shows your layout in colour, rendered through the same pipeline
tmux gets, updating as you make changes — before you've saved anything. It
sits on one line when the regions fit side by side; if your terminal is too
narrow, each region drops onto its own line (left-, centre-, and
right-aligned as they are on the real bar) rather than being clipped.
w writes your changes to config.toml; q quits (asking to confirm first
if you have unsaved changes); ? shows a help overlay.
In tmux, prefix + W opens this editor in a popup (rustline init wires
this up for you — see Enable in tmux above), so you can
tweak your layout without leaving your session.
CENTER is fixed. tmux renders the window list there itself — nothing in
rustline reads [layout].center at render time — so the interactive editor
refuses to edit it (with an on-screen explanation) and the CLI (widget enable|move --region center) still writes the change, to keep the default
center = ["windows"] round-trippable, but prints a note that it won't
affect what you see on the bar.
The same widget can appear more than once in your layout with different
options via a top-level [instances.<name>] table — pick any name you like,
set kind to the widget kind you want, and the rest of the table is that
kind's usual options. Add the instance's name (not the kind) to a layout
array to place it:
[layout]
right = ["clock_utc", "datetime", "disk_data", "disk"]
[instances.clock_utc]
kind = "datetime"
timezone = "UTC"
format = "%H:%MZ"
[instances.disk_data]
kind = "disk"
mount = "/data"This works for any of the twelve widgets that support click-to-toggle
(datetime, lan_ip, tailscale_ip, battery, cpu, memory, loadavg,
git, disk, uptime, media, throughput) — handy for a second clock in
another timezone, usage bars for more than one disk mount, or per-interface
throughput. An instance name follows the same rule as a click-toggle name
(letters/digits/_/-, at most 15 bytes) and can't reuse a built-in
widget's name — that name always wins, and a colliding or malformed instance
is skipped with a log warning rather than breaking your config.
hostname and pane_id are both in the default left layout. Each takes a
format (previously a fixed string): hostname's (default "{host}")
substitutes {host}, the hostname truncated at the first .; pane_id's
(default "{session}:{window}.{pane}") substitutes {session}/{window}/
{pane}. Any other text — e.g. a Nerd-Font icon or label — is printed
verbatim, and both defaults reproduce the pre-config output byte-for-byte.
[widgets.hostname]
format = " {host}" # default "{host}"
[widgets.pane_id]
format = " {session}/{window}/{pane}" # default "{session}:{window}.{pane}"cwd is in the default right layout. Beyond the existing abbreviate_home
(default true, a leading $HOME becomes ~), it now takes a format
(default "{path}", substituting the — possibly shortened — path) plus two
more shortening options, applied in order (home-abbreviation, abbreviate,
max_depth, max_len, then the format substitution): abbreviate
(default false, fish-shell style — every path component but the last
shrinks to its first character, e.g. ~/src/rustline → ~/s/rustline),
max_depth (default 0 = unlimited; keeps only the last N /-separated
components, prefixing a leading …/ when components are dropped), and
max_len (default 0 = unlimited; left-truncates the result to at most N
characters, prefixing a leading …). Every option at its default reproduces
the pre-feature output byte-for-byte.
[widgets.cwd]
format = "{path}" # default
abbreviate_home = true # default
abbreviate = true # fish-style shortening of all but the last component
max_depth = 3 # keep only the last 3 path components
max_len = 30 # left-truncate to 30 charactersTwo opt-in built-ins show the machine's addresses: lan_ip (your LAN IPv4) and
tailscale_ip (your Tailscale IPv4, the 100.64.0.0/10 address). Neither is in
the default layout — add either to a layout region to use it.
Each takes a format where {ip} is replaced by the address and any
surrounding label or glyph is printed verbatim, and a down_format shown when
the address isn't available (default empty — the widget then renders nothing
rather than a stale or fake address). lan_ip auto-picks the first private,
non-virtual interface (container/VM bridges like docker0/virbr0 and the
Tailscale interface are skipped); set interface to force a specific NIC. Both
also take an alt_format for click-to-toggle.
[layout]
right = ["lan_ip", "tailscale_ip", "cwd", "loadavg", "datetime"]
[widgets.lan_ip]
format = "LAN {ip}" # {ip} -> 192.168.1.20; or a glyph, e.g. " {ip}"
# interface = "wlp3s0" # optional; omit to auto-pick
[widgets.tailscale_ip]
format = "TS {ip}"
down_format = "TS off" # shown when Tailscale is down; omit to render nothingAn opt-in battery built-in shows charge percentage, state, and a
level-bucketed, charging-aware Nerd-Font icon. It works on Linux (sysfs) and
macOS (pmset); on any other platform, or a host with no battery, it renders
nothing by default.
Takes a format where {icon}, {percent}, and {state} are replaced, and
a down_format shown when there's no battery reading (default empty — same
collapse-to-nothing behavior as the IP widgets' down_format), plus an
alt_format for click-to-toggle. It's also
threshold-aware: warn_percent/crit_percent (default 20/10)
alert while discharging at or below those levels. An optional icon
overrides {icon} with a fixed glyph instead of the computed one (default:
None, keep the computed glyph) — handy without a Nerd Font.
[layout]
right = ["battery", "cwd", "loadavg", "datetime"]
[widgets.battery]
format = "{icon} {percent}%" # {icon}, {percent}, {state}
down_format = "" # shown when no battery (desktops); default: nothing
warn_percent = 20 # default; alert badge at/below this % while discharging
crit_percent = 10 # default; 0 disables a tier
# icon = "BAT" # optional; overrides the computed level/charging glyphcpu and memory are built-in and in the default right layout (unlike
the opt-in widgets above) — they show live CPU utilization and memory usage,
each with a Unicode gauge bar.
cpu takes a format (default "{icon} {percent}%") with {icon}
(nf-md-chip), {percent}, {bar}, and {spark} placeholders. memory takes
a format (default "{icon} {used}/{total}") with {icon} (nf-md-memory),
{used}/{total}/{avail} (human-readable binary sizes, e.g. 6.2G),
{percent}, {bar}, and {spark} placeholders. {bar} is a bar_width-cell
(default 8) Unicode block-eighths gauge shared by both widgets; {spark} is a
Unicode sparkline of the last spark_width readings (default 8), persisted
across refreshes. Both also take a
down_format (default empty) shown on an unsupported platform or a failed
read — same collapse-to-nothing behavior as the battery widget's
down_format — and an alt_format for
click-to-toggle. Both are also
threshold-aware: warn_percent/crit_percent (cpu default 80/95,
memory default 80/92) alert at or above those levels. Each also takes its
own optional icon that overrides {icon} with a fixed glyph instead of
the built-in Nerd-Font one (default: None, keep the built-in glyph).
[widgets.cpu]
format = "{icon} {spark} {percent}%" # default "{icon} {percent}%"
bar_width = 8
spark_width = 8 # {spark} history length (last N readings)
warn_percent = 80 # default; 0 disables a tier
crit_percent = 95 # default
# icon = "CPU" # optional; overrides the built-in Nerd-Font glyph
[widgets.memory]
format = "{icon} {used}/{total}" # default; or "{icon} {spark} {percent}%"
bar_width = 8
spark_width = 8
warn_percent = 80 # default; 0 disables a tier
crit_percent = 92 # default
# icon = "MEM" # optional; overrides the built-in Nerd-Font glyphloadavg is built-in and in the default right layout — it shows the
1/5/15-minute system load average (nothing on platforms where it can't be read,
rather than fake zeros).
Takes a format with {load1}/{load5}/{load15} placeholders, each of which
accepts an inline Rust-style precision spec :.N — {load1:.1} → 0.4. A bare
{load1} is two decimals (so the default renders exactly like older versions),
and N is clamped to 0–10. Also takes a down_format (default empty, shown
when the load can't be read) and an alt_format for
click-to-toggle. It's also
threshold-aware on load1 via warn_load/crit_load — unlike the
other numeric widgets, both default to 0.0 (off), since an absolute load
threshold depends on core count.
[widgets.loadavg]
format = "{load1} {load5} {load15}" # default
alt_format = "{load1:.1} {load5:.1} {load15:.1}" # left-click toggles to this
warn_load = 0.0 # default (off); e.g. 4.0 on a 4-core box
crit_load = 0.0 # default (off)An opt-in git built-in shows the current branch (or a 7-character short SHA
when HEAD is detached), a dirty marker, and ahead/behind/staged/unstaged
counts for the pane's working directory, read by shelling out to git status --porcelain=v2 --branch. Not in the default layout — add it to a layout
region to use it. When git is missing, the pane isn't inside a repository,
or the read fails, it renders nothing by default.
Takes a format with {branch}, {ahead}, {behind}, {staged},
{unstaged}, and {dirty} placeholders ({dirty} substitutes dirty_glyph
when there's any staged or unstaged change, else nothing), a down_format
shown when there's no git reading (default empty — same collapse-to-nothing
behavior as the other widgets' down_format), and an alt_format for
click-to-toggle.
[layout]
right = ["git", "cwd", "loadavg", "datetime"]
[widgets.git]
format = " {branch}{dirty}" # default: Nerd-Font branch glyph
dirty_glyph = "*" # default
down_format = "" # shown outside a repo; default: nothingAn opt-in disk built-in shows filesystem usage for a configured mount
(default /), read via statvfs(2). Not in the default layout — add it to a
layout region to use it. When the mount can't be statvfs'd, it renders
nothing by default.
Takes a mount (default /), a format (default " {used}/{total}", no
icon) with {used}/{total}/{avail} (human-readable binary sizes, e.g.
6.2G), {percent}, {bar} (a bar_width-cell, default 8, Unicode
gauge — the same one cpu/memory use), and {mount} (the configured mount
string itself) placeholders, a down_format shown when there's no disk
reading (default empty — same collapse-to-nothing behavior as the other
widgets' down_format), and an alt_format for
click-to-toggle. It's also
threshold-aware: warn_percent/crit_percent (default 85/95)
alert at or above those levels.
[layout]
right = ["disk", "cwd", "loadavg", "datetime"]
[widgets.disk]
mount = "/" # default
format = " {used}/{total}" # default
bar_width = 8
down_format = "" # shown when the mount can't be read; default: nothing
warn_percent = 85 # default; alert badge at/above this %
crit_percent = 95 # default; 0 disables a tierTwo more opt-in built-ins, neither in the default layout:
uptime shows system uptime, humanized to the coarsest unit pair (3d 4h,
1h 15m, 12m, <1m). Its format (default "{uptime}") substitutes
{uptime}; on a platform where uptime can't be read it renders nothing (or its
down_format).
media shows the current now-playing track via playerctl (Linux). Its
format (default "{title} — {artist}") substitutes {artist}, {title},
and {status}; when playerctl is missing or no player is running it renders
nothing (or its down_format) rather than a fake "not playing". Both also take
an alt_format for click-to-toggle.
[layout]
right = ["media", "uptime", "cwd", "datetime"]
[widgets.uptime]
format = "up {uptime}"
[widgets.media]
format = "♪ {title} — {artist}"
down_format = "" # nothing when nothing is playingAn opt-in throughput built-in shows network download/upload rates, read from
/proc/net/dev (Linux). Not in the default layout — add it to a layout
region to use it. A rate is a delta between two counter samples, so it renders
nothing on the first refresh (nothing yet to diff against), and nothing on
a non-Linux platform or a failed read — never a fake 0.
Takes a format (default " {down} {up}", no icon) with {down}/{up}
placeholders (human-readable binary sizes suffixed /s, e.g. 1.2M/s), an
optional interface (pin to a single NIC; omit to aggregate every non-loopback
interface), a down_format (default empty), and an alt_format for
click-to-toggle.
[layout]
right = ["throughput", "cwd", "datetime"]
[widgets.throughput]
format = " {down} {up}" # default
# interface = "eth0" # optional; omit to aggregate all NICs
down_format = ""Any format-bearing widget accepts optional fg/bg keys to pin its colors,
using the same Color shapes as a theme ({ Indexed = N },
{ Named = "cyan" }, or { Rgb = [r, g, b] }). fg always applies; bg only
applies where the widget doesn't already have an explicit background (so it
won't fight a threshold alert badge). Leave them unset to use the theme's
cycling palette as before.
[widgets.cwd]
fg = { Indexed = 250 }
bg = { Rgb = [40, 44, 52] }Beyond the default left-click toggle, a widget can bind a specific mouse button
to open a URL or run a command, as left_click / right_click /
middle_click under its [widgets.<name>] table:
[widgets.cpu]
right_click = { run = "tmux display-popup -E htop" }
middle_click = { open_url = "https://grafana.example/host" }
# left_click = { toggle = false } # disable the default left-click toggleThe command in a run binding comes only from your own config (never from
anything tmux passes in), and runs detached via sh -c. One catch: a
widget only receives clicks once it emits a clickable range, which today
happens when it has a non-empty alt_format. So a run/open_url binding on
a widget with no alt_format won't fire — give the widget an alt_format
(even a throwaway one) to make it clickable. This needs the same
MouseDown{1,2,3}Status bindings the rustline init wizard writes and
set -g mouse on.
datetime, lan_ip, tailscale_ip, battery, cpu, memory, loadavg,
git, disk, uptime, media, and throughput each take an alt_format (default empty). Give a widget a non-empty
alt_format and it becomes clickable: left-clicking it in the tmux status
line toggles it between format and alt_format, e.g. a compact CPU reading
swapping to one with a gauge bar:
[widgets.cpu]
format = "{icon} {percent}%"
alt_format = "{icon} {bar} {percent}%" # left-click toggles to thisThis needs tmux ≥ 3.1 and set -g mouse on (see Enable in
tmux above) — the rustline init wizard can turn mouse mode
on for you if you opt in, or rustline init --print just wires the click
handler and leaves mouse mode to you. Which widgets are currently toggled is
tracked globally (not per pane/session) in a small state file under
$XDG_DATA_HOME/rustline, written by the rustline click subcommand the tmux
binding invokes. WASM plugins can support this too: a plugin is clickable when
its name is 15 bytes or less (tmux's status-range name limit), and it decides
for itself whether to honor a click by checking context.toggled — the
bundled weather example does this via options.alt_format. Since a plugin's
name becomes a tmux range=user|<name> argument verbatim, pick one that is
≤ 15 bytes, isn't the reserved name window, and sticks to [A-Za-z0-9_-].
rustline ships seven built-in themes, selectable from the command line, plus per-widget threshold alerts that use each theme's semantic colors.
Truecolor requirement: the six curated themes (everything but
default) use truecolor (24-bit RGB) values. You need a truecolor-capable terminal and tmux'sRGB/Tcterminal feature enabled — e.g.set -as terminal-features ",xterm-256color:RGB"(tmux ≥ 3.2), orset -ga terminal-overrides ",*256col*:Tc"on older tmux — otherwise the colors will be approximated or look wrong.
default— the original two-accent palette (unchanged).pastel-rainbow— the flagship: a six-color pastel palette with dark text.nord,gruvbox,catppuccin-mocha,tokyo-night,dracula— curated multi-accent ports of the popular color schemes.
rustline theme list # built-ins + your themes-dir files, active marked *
rustline theme list --json # same list as a JSON array, for scripting
rustline theme show pastel-rainbow # ANSI preview (with sample alert badges)
rustline theme use nord # sets [theme].base = "nord" in config.toml
rustline theme new my-nord --from nord # scaffold a tweakable copy to edit by hand
rustline theme pick # interactively browse previews, then set onetheme pick lists the themes (active marked, themes-dir files tagged
(custom)), lets you preview any by number (or a/all for every one), then
prompts you to set one by name or number (blank keeps the current theme).
Previews default to a healthy status line — just the theme's palette, the
way you'll actually see it day to day. Press t to toggle the warning/error
alert-badge colors on, so you can sample a theme's semantic colors, and t
again to turn them back off; toggling immediately re-shows the theme you last
previewed, so you see the change right away. It needs a terminal — a
non-interactive invocation prints a hint to use theme show/theme use
instead and exits non-zero without writing anything.
theme new writes a complete, commented theme file to
$XDG_CONFIG_HOME/rustline/themes/<name>.toml (fallback
~/.config/rustline/themes) with every field set to the seed theme's values —
edit any of them, then rustline theme use <name>. Add --edit to open the
new file in $EDITOR right away (needs a TTY); either way it prints the
theme use <name> follow-up. A themes-dir file always
shadows a built-in of the same name, so rustline theme new nord followed
by rustline theme use nord uses your tweaked copy.
Under the hood, [theme].base in config.toml selects the starting theme;
any individual [theme] field — the six window-pill fields (win_current_bg/
win_current_fg/win_inactive_bg/win_inactive_fg/win_cap_left/
win_cap_right), palette, fg, bar_bg, the separators, or the semantic
colors below — still overrides on top:
[theme]
base = "nord" # a built-in name, or a *.toml stem in your themes dir
warning = { Named = "yellow" } # per-field overrides still apply on top of baseEvery theme defines four semantic colors — success, info, warning,
error — available to widgets and WASM plugins alike. The cpu, memory,
battery, loadavg, and disk widgets use them for threshold alerts: cross a
configured warn_*/crit_* level (see each widget's section above) and the
whole segment flips to an inverse badge (bold text in the theme's bar_bg,
background in the semantic color) — critical always wins over warning. Set a
threshold to 0 to turn that tier off; loadavg's thresholds default off
since a meaningful absolute load number depends on your core count.
rustline writes logs to ~/.local/share/rustline/rustline.log
($XDG_DATA_HOME/rustline/rustline.log) at info by default, and error-level
messages to stderr. The file rotates to rustline.log.1 once it exceeds 5 MiB.
Raise the file verbosity with repeated -v (file sink only):
| flag | file level |
|---|---|
| (none) | info |
-v |
warn |
-vv |
info |
-vvv |
debug |
-vvvv |
trace |
Override either sink in config.toml (RUST_LOG is not used):
[log]
file_level = "info" # off|error|warn|info|debug|trace
stderr_level = "error"
file = "~/.local/share/rustline/rustline.log" # optional
Every render command accepts --preview, which prints the region in ANSI colour
instead of raw tmux markup — handy for eyeballing the bar without wiring it into
tmux:
rustline render left --preview --session=0 --window=1 --pane=0 --pane-path="$PWD"A just recipe previews the whole bar (left region,
window list, right region) at once — using your live tmux context when run
inside tmux, and sample values otherwise:
just previewOther recipes: just build, just test, just lint, just lint-plugins and
just test-plugins (fmt/clippy/tests for the excluded example plugins under
plugins/*, which the root cargo commands never see).
rustline bench (build with --features bench, or run just bench) times the
render pipeline: a pure pass on a fabricated Context (no OS reads, so no
read_cpu ~120 ms sample) versus a real-world pass that pays the real
reads, plus per-widget, per-data-source, and per-plugin breakdowns as tables.
just bench # all groups
just bench --only widgets # just the per-widget render costs
cargo run --features bench -- bench --format markdown --output bench.mdPlugin passes run against the real, preserved plugin state/cache, so a cached
plugin (e.g. weather) is measured on its fast cached path rather than
re-fetching every iteration. A separate build-timing pass measures each
plugin's cold build_plugin with the on-disk wasmtime compile cache off vs on
— on a warm cache a cold spawn deserializes the precompiled module instead of
re-running the compiler (measured ~13× faster per plugin).
just bench --only daemon (included in the default all run) A/B's a
plugin-heavy layout's left/right render in-process (cold — a fresh
registry, re-instantiating every WASM plugin, same as a daemon-less render)
against a real round-trip to a reachable daemon. It never
starts a daemon itself: with none reachable, it prints a note telling you to
start one (rustline daemon run) instead of measuring nothing. The win is
biggest on a layout with plugins in it — a layout with none may show little
or even a slight loss (socket round-trip overhead) once you account for it.
By default every rustline render invocation is a fresh, self-contained
process — it parses your config, resolves your theme, builds the widget
registry, and (for left/right) discovers/instantiates any WASM plugins,
every single time tmux fires status-interval. That's normally fast enough
not to notice, but an optional persistent daemon can keep all of that warm
across refreshes instead, so a render becomes a quick socket round-trip
against already-built state:
rustline daemon run # or just `rustline daemon`; runs in the foreground
rustline daemon status # is it up and reachable?
rustline daemon stop # ask it to shut downrustline daemon run does not background itself — run it under a
supervisor. The easiest way is the built-in installer, which uses your
platform's native per-user service manager (systemd on Linux, launchd
on macOS) so the daemon starts at login:
rustline daemon install # write the service file, load + start it now
rustline daemon install --write-only # write the file only; you load it
rustline daemon uninstall # unload/stop it and remove the fileOn Linux, rustline daemon install generates a systemd user unit at
$XDG_CONFIG_HOME/systemd/user/rustline-daemon.service (falling back to
~/.config/systemd/user/) whose ExecStart calls the running binary's own
resolved absolute path (override with --binary <path>), then runs
systemctl --user daemon-reload and enable --now for you. If systemctl
isn't on PATH (or you passed --write-only), it prints the manual
systemctl --user enable --now rustline-daemon.service command instead.
On macOS, it writes a launchd LaunchAgent at
~/Library/LaunchAgents/rustline-daemon.plist (RunAtLoad starts it at every
login; KeepAlive restarts it only if it crashes — a clean daemon stop
stays stopped), then loads it with launchctl bootstrap gui/$UID. If
launchctl isn't on PATH (or you passed --write-only), it prints the
manual launchctl bootstrap gui/$(id -u) … command instead.
Either way the service file is written first and a systemctl/launchctl
failure is never fatal. rustline daemon uninstall best-effort unloads the
service and removes the file; running it again once already uninstalled is a
no-op.
For reference, on Linux the unit it writes looks like this:
# Managed by `rustline daemon install`.
[Unit]
Description=rustline render daemon
[Service]
ExecStart=/home/you/.local/bin/rustline daemon run
Restart=on-failure
[Install]
WantedBy=default.target…and on macOS, the LaunchAgent plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Managed by `rustline daemon install`. -->
<plist version="1.0">
<dict>
<key>Label</key>
<string>rustline-daemon</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.local/bin/rustline</string>
<string>daemon</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
</dict>
</plist>If you'd rather manage it by hand, write the appropriate file yourself (with your own binary path) and run:
systemctl --user enable --now rustline-daemon.service # Linux
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/rustline-daemon.plist # macOSOr start it from tmux itself with a run-shell line in ~/.tmux.conf
(outside the managed rustline init block, so it isn't removed by a
future re-run):
run-shell "rustline daemon status >/dev/null 2>&1 || rustline daemon run >/dev/null 2>&1 &"You never have to think about whether it's running. Every render call
tries the daemon's Unix socket first and transparently falls back to the
normal in-process render on any failure — the socket doesn't exist, the
daemon isn't listening, a request times out, whatever. There's no wizard
question for this yet and no auto-spawn: it's entirely opt-in, and
rustline doctor reports whether a daemon is currently reachable
(advisory only — never a failing check). Want numbers instead of a vibe? Start
one and run rustline bench --only daemon (see Benchmarking)
to A/B a render against it. The daemon reloads its config
automatically when config.toml's modification time changes, so editing
your config doesn't require a restart. One thing it does not pick up
live: a per-invocation render --plugin-dir=X is ignored while a daemon is
reachable, since the daemon always serves with whatever --plugin-dir it
was started with — restart it if you need to point it at a different
plugin directory.
Third-party widgets can be added as WASM plugins. A plugin is a small wasm
module (built for wasm32-unknown-unknown with the Extism PDK)
that exports a name function and a render(context, config) -> Segment[]
function — the same Context in, Segments out contract as a built-in
widget, just crossing the wasm boundary as JSON.
Everything a plugin can touch is capability-gated by the host: network
requests, arbitrary file paths, and running local commands are checked
against per-plugin allowlists in your config (allowed_urls / allowed_paths
/ allowed_commands, each a glob or a re:-prefixed regex; re: patterns
are anchored to a full-string match, so include .* for a
prefix, e.g. re:https://wttr\.in/.*), and each plugin gets its own sandboxed
state directory with a size
quota (max_state_bytes, default 50 MB) for caching data between renders. The
host also exposes a TTL-cached HTTP GET (and a TTL-cached exec — see below),
so a plugin can fetch remote data or run a slow command at most once per
interval without managing its own cache — the bundled weather
example uses the former. A plugin has no ambient access to anything — a
disallowed request is simply refused, and any plugin error, timeout, or
crash renders as an empty segment rather than breaking the status line.
Plugin security, plainly: an approved allowed_commands entry lets a
plugin run a real program with your own environment variables and user
permissions — the host does not sandbox the command itself, only whether
it can run and which command lines. A pattern matches the whole command
line (program plus every argument, not just the program name), so a grant
for git status* does not cover git push --force. And there is no shell
involved unless you explicitly grant one (allowed_commands = ["sh -c *"])
— the host always spawns the program directly, so nothing re-parses or
word-splits what a plugin sends it. Only approve allowed_commands patterns
you actually understand.
Five worked examples ship under plugins/, each demonstrating a different
host capability:
| Plugin | Capability | Renders |
|---|---|---|
weather |
rl_http_get_cached (TTL-cached GET) |
a condition icon + °F for a zip code |
counter |
rl_state_read/rl_state_write |
a count that persists across renders |
filewatch |
rl_file_read |
a configured file's first line + line count |
httpget |
rl_http_get (plain, uncached) |
a snippet of a fetched URL's body |
cmdrun |
rl_exec/rl_exec_cached |
a snippet of a command's stdout |
Build and install any of them with the generic recipe:
just build-plugin weather # or counter / filewatch / httpget / cmdrun(just build-weather still works too — it's now just an alias for
just build-plugin weather.)
Then add one to your layout and grant it whatever it needs. For weather,
that's a URL allowlist:
[layout]
right = ["weather", "cwd", "loadavg", "datetime"]
[plugins.weather]
allowed_urls = ["https://wttr.in/*"]
[plugins.weather.options]
zip = "48183"
format = "{icon} {temp_f}°F"
refresh_secs = 1800counter needs no allowlist at all (it only touches its own sandboxed state
dir):
[layout]
right = ["counter", "cwd", "datetime"]
[plugins.counter.options]
format = "seen {count}x"filewatch needs a path allowlist (allowed_paths), and httpget a URL
allowlist, same shape as weather's:
[plugins.filewatch]
allowed_paths = ["/home/steve/.cache/build-status"]
[plugins.filewatch.options]
path = "/home/steve/.cache/build-status"
format = "{first_line} ({lines}L)"
[plugins.httpget]
allowed_urls = ["https://example.com/status"]
[plugins.httpget.options]
url = "https://example.com/status"
format = "status: {body}"
max_chars = 40cmdrun needs a command allowlist (allowed_commands), matched against the
whole command line:
[layout]
right = ["cmdrun", "cwd", "datetime"]
[plugins.cmdrun]
allowed_commands = ["uname", "uname *"] # program "uname", any args (globs match "*" literally, not a prefix)
[plugins.cmdrun.options]
program = "uname"
args = ["-sr"]
format = "{out}"All four of the newer examples also demonstrate rl_log: each logs a
warn-level message through the host's tracing subscriber on its one
failure path (a denied/failed state write, an unreadable file, a non-2xx HTTP
response, or a denied/failed/non-zero-exit command) rather than staying
silent.
Manage a plugin's allowlists from the command line without hand-editing TOML:
rustline plugin list
rustline plugin list --json # same info as a JSON array, for scripting
rustline plugin url add weather "https://wttr.in/*"
rustline plugin url list weather --json # its allowed_urls as a JSON array of strings
rustline plugin cmd add cmdrun "uname *" # allowed_commands works the same wayA plugin can also declare a capability manifest — a sidecar
<plugin_dir>/<name>.toml (or an embedded rustline-manifest wasm custom
section) listing the allowed_urls/allowed_paths/allowed_commands it
wants — so you don't have to hand-write them yourself:
rustline plugin approve weather # shows what it requests, asks to confirm
rustline plugin approve weather --yes # non-interactive; for scripts
rustline plugin approve cmdrun # commands get an extra, explicit warningapprove writes exactly the requested patterns into
[plugins.<name>]'s allowlists (never more than what's declared), and does
nothing if the plugin has no manifest.
Not sure what plugins exist yet? Browse the curated index:
rustline plugin search # list every entry in the index
rustline plugin search weather # case-insensitive filter over name + description
rustline plugin search --json # machine-readable, for scripting
rustline plugin search --refresh # bypass the 24h cache and fetch nowSample output:
weather
Nerd-Font condition icon + °F for a configured zip code, via wttr.in
capabilities: http_cached
build: just build-plugin weather
The index is a small, versioned JSON document, fetched from the built-in
default over HTTPS (a GitHub-hosted raw.githubusercontent.com URL) and
cached for 24h at <state_root>/plugin-index.json. That's only the default,
though — plugin_index_url (below) is passed straight to the same ureq
client plugin install uses, so a plain http:// mirror works too (unlike
the default, it just isn't verified in transit). A fetch failure serves the
last-known-good cached copy (with a warning) rather than failing outright, as
long as something has been cached before. Each entry marks whether it's
already present in your plugin dir, and shows either a just build-plugin <name> hint (for a bundled example) or an rustline plugin install <owner/repo> hint (for an installable one). Point search at a different
index — your own fork, or a self-hosted mirror — with plugin_index_url in
config.toml:
plugin_index_url = "https://example.com/my-index.json" # optional; overrides the built-in defaultEach entry's capabilities list is advertising copy only — it tells you what
a plugin will ask for before you install it, but grants nothing on its own;
only plugin approve (or a hand edit) ever widens an allowlist. search does
read your config (for plugin_index_url) and your plugin dir (to mark
already-installed entries) — but it never modifies either: it never writes
config.toml, never installs or removes a plugin, and never touches the
toggle state. Its only write is the index cache itself, at
<state_root>/plugin-index.json.
Install a published plugin straight from a GitHub release instead of building it yourself:
rustline plugin install owner/repo # downloads the .wasm into your plugin dir
rustline plugin update my-widget # re-fetch the latest release
rustline plugin remove my-widget --yes # delete the .wasm and its config entryinstall downloads the plugin's .wasm, records its source/tag/checksum
in [plugins.<name>], and grants no capabilities — you still run
rustline plugin approve (or plugin url|path add) to allow anything. If a
plugin is ever denied something it asked for, rustline plugin denials <name> lists every capability it was refused, so you can decide whether to
grant it — add --json for a machine-readable {kind, target} array.
That recorded checksum is verified every time the plugin loads, not just
recorded and forgotten. Before a discovered .wasm is handed to the
sandbox, rustline hashes its bytes and checks them against
[plugins.<name>].checksum:
- No checksum recorded — the key is absent, or set to an empty/whitespace value → loads exactly as before (nothing to check). Clearing the field is how you opt a plugin back out of verification.
- Recorded and matching → loads normally.
- Recorded but the bytes don't match → skipped, with a warning in the log — the file on disk changed since the digest was recorded, which is exactly the case this feature exists to catch.
- Recorded but not a valid sha256 digest (wrong length, non-hex characters, etc.) → also skipped — a checksum this build can't even parse is treated as a failed check, not as "no checksum," so a typo can't silently disable verification.
The one exception is rustline plugin run, the dev harness you use right
after rebuilding a plugin — where a recorded digest legitimately goes stale
on every build. It only warns on a bad checksum and still runs the plugin.
Your actual status line, the daemon, and every other real load always
enforce the check.
Rebuilding an installed plugin's stale checksum is handled for you. Only
plugin install/plugin update ever record [plugins.<name>].checksum;
plugin build (and just build-plugin, which now routes through it) just
installs the freshly built .wasm. If that plugin already has a recorded
checksum and the rebuilt bytes no longer match it, plugin build notices and
offers to fix it right there: on a terminal it asks Update the recorded checksum? [Y/n] (Enter means yes — refreshing a hash to match bytes you just
built yourself isn't a new capability grant, unlike plugin approve's
confirm), and --yes does the same non-interactively. On a non-TTY run
(CI, a script) without --yes, it deliberately does not prompt and does
not rewrite the checksum — it prints a notice and leaves the stale digest
in place, so the plugin keeps failing the load-time check until you refresh
it explicitly. That's by design: silently re-pinning in a non-interactive
context would let a compromised local toolchain bless its own output, which
is exactly what checksum verification exists to catch. It stays completely
quiet whenever there's no checksum recorded or the bytes already match.
For that non-TTY case — or if you'd rather not go through plugin build at
all — the manual recovery paths still work: clear
[plugins.<name>].checksum in your config (opts that plugin back out of
verification), or, if it has a recorded GitHub source, run rustline plugin update <name> — though that re-downloads the published release and
overwrites your local rebuild, so only use it if you're fine reverting to
what's published.
What this actually protects against. Checksum verification is a real
integrity check, not a security perimeter: it catches accidental swaps,
truncated or corrupt downloads, stale build artifacts, and a plugin dir that
somehow ended up writable by a different principal than your config — worth
having, but bounded. It does not stop a same-user attacker: anyone who can
write to ~/.local/share/rustline/plugins/*.wasm can just as easily edit
~/.config/rustline/config.toml and delete the checksum line — there's no
privilege boundary between the two, and no strict/"require checksum" mode that
would refuse to load an unpinned plugin. It also does not pin against a
compromised upstream: plugin install/plugin update both rewrite the
digest from whatever they just downloaded (trust-on-first-use), so a
compromised release is recorded and then dutifully verified against itself.
Scaffold a new plugin crate instead of copying weather by hand:
rustline plugin new my-widget --path pluginsThis writes plugins/my-widget/Cargo.toml (edition 2024, crate-type = ["cdylib"], an empty [workspace] table so it builds standalone rather than
joining rustline's own workspace) and plugins/my-widget/src/lib.rs (a
name/render skeleton built on the rustline-plugin-sdk crate, which bundles
the typed host-capability wrappers, the GuestRender/WireContext wire types,
and an export_plugin! macro so you write one crate instead of hand-rolling
the Extism glue), then prints the cargo build --target wasm32-unknown-unknown
- install command and a starter
[plugins.my-widget]config snippet. The plugin name must be[A-Za-z0-9_-], at most 15 bytes, and notwindow— the same rule as a widget's click-to-toggle range name, since it becomes a tmuxrange=user|<name>argument verbatim. Pass--forceto overwrite an existing scaffold.
Build any plugin crate and install its .wasm into your plugin dir in one
step with rustline plugin build <dir>, or dry-run one against a sample
Context (printing its segments and any capability denials, without touching
your config) with rustline plugin run <name>. Plugins negotiate a small ABI
version with the host, so a plugin built against an incompatible future ABI is
skipped rather than loaded — existing plugins keep working.
See the design spec for the full capability model, config schema, and plugin ABI, the widget manager + exec capability design spec for the exec capability's gating/bounds detail and the layout-editing CLI/TUI, and the plugin integrity + registry design spec for the checksum-verification policy and the curated-index schema.
See the full design specs: core statusline, WASM plugins, IP widgets, CPU/memory widgets, click-to-toggle widgets, themes/theme picker, whats-next bundle, widget manager + exec capability, plugin integrity + registry + CI/CD.
MIT — see LICENSE.