Skip to content

The profile

You don’t have to write tabs by hand: arrange the panes in zellij and phosphor keep writes that tab here for you.

~/.config/phosphor/deck.toml (override: PHOSPHOR_PROFILE). phosphor init writes it, phosphor setup edits it, you can edit it by hand; after a hand edit run phosphor gen && phosphor restart. Files that gen writes say “GENERATED BY phosphor gen”: never edit those.

key default what
session “deck” the session’s name; it shows in the tab bar
command “deck” the one-word command that gets you in (~/.local/bin/<it>)
theme “p31” p31 green, p3 amber, p4 white, paper (e-ink)
mount_root “~/fleet” where the fleet’s files appear
projects “~/projects” where workspaces are made (phosphor workspace)
mesh auto tailscale, headscale, none (plain ssh), or auto: detect
bar “tabs” tabs (tab bar with its + menu), compact, full (adds key hints)
editor your $EDITOR, else nano what panes open files with
shell your login shell what shell panes, + → shell and zellij’s new panes run (bash, zsh, fish…)
connect “ssh” how other computers attach: ssh (mouse, touch) or mosh
web false the deck in a browser, tailnet only (phosphor web on)
web_port 8443 the HTTPS port tailscale serve publishes it on
notifier false floating adjutant on every tab; off by default – see notifier below
graphs “braille” “blocks” draws the graphs with block characters, for fonts without Braille
face “” adjutant face (see phosphor face)
notify_seconds 8 how long a floating notice stays (notifier = true)
demo false fleet fakes its readings instead of polling ssh (what phosphor demo sets)

notifier opens a floating pane per tab for phosphor notify’s toast. It’s off by default: zellij 0.45’s screen thread froze twice in real use, both times right after hide-floating-panes -t ran across every tab – shown or not – to clear the toast. No isolated repro exists for the freeze itself (see issue #14), but that “touch every tab, whether it needs it or not” pattern was the only clue there was, and it’s gone: phosphor notify now targets only the tab it actually showed the toast on, both to show it (a bare show-floating-panes, with no tab named, can itself answer “Tab not found” when called from outside the client – naming the tab fixes that too) and to hide it again. If you turn notifier on, this is lower-risk than before, not risk-free.

theme recolors more than zellij and the web client: phosphor gen also writes it into yazi’s theme.toml, btop’s own phosphor theme (set as color_theme in btop.conf), and passes it to gping and ctop on their command line (ctop only tells light from dark, so paper is the one theme that inverts it). Each is left alone the moment it holds something gen didn’t write itself – a color_theme you picked, a theme.toml of your own.

One block per machine.

key what
name its name in the deck
role brain (exactly one, and local), work, desktop, storage, node, viewer
local true on the brain
ssh ssh alias or host name
user only if your user there differs
ip optional; gping pings it
mount remote folder shown in ~/fleet/<name> (rclone sftp through your own ssh: ssh config, keys, known_hosts)
mounts the brain’s own folders shown in ~/fleet/<brain>/ (/ → root, ~ → home, /mnt/X → X)
fleet false keeps it in the deck but off the fleet panel

Viewers are never polled or mounted. @work, @desktop… in a tab mean “the first host with that role”; @hosts means all of them; @mount_root is ~/fleet.

In order; Alt-1..9 go to them. name, optional split (“rows” or “cols”), and panes, a list of pane specs:

key what
cmd the program (phosphor pulse, yazi, …); empty: a shell
args its arguments (@... tokens expand, see above)
ssh a machine to ssh into instead of a program
reconnect ssh panes: retry when the link drops (default true)
size lines/columns, or a percentage like “55%”
cwd working folder
needs_size wait a second before starting (yazi, ctop read the size once)
alt start on the alternate screen (gping, ctop)
split, panes a container with its own panes

Every program runs through phosphor run (see keys).

A [[tabs]] block dropped into ~/.config/phosphor/tabs.d/*.toml (one file, same shape as above) shows up in the deck without touching your own profile – pass someone a file, they drop it in, phosphor gen. They come after your own tabs, in file-name order; a name your profile already uses wins.

Their content is read-only from the tools that edit your profile: Alt-r’s save and phosphor keep say which tabs.d/*.toml a tab comes from instead of touching it. Edit that file by hand, then phosphor gen, to change one.

Their position isn’t: phosphor tabs can still move one with K/J, the same as any tab. The first time, it writes a name-only stub into your profile – [[tabs]] name = "BUBBLE" and nothing else – marking where it goes; its content still comes from tabs.d. f on a placed one takes the stub back out (it returns to the end, in file-name order), not the tab itself; the file it came from is never touched.

phosphor recipe drops a starter bundle in this same way (see commands).

key what
host an ~/.ssh/config alias whose LocalForward lines the deck keeps up
config optional: another ssh config file for it

The deck’s own keys (phosphor shortcuts changes them for you). Anything left out keeps its default; "" turns one off.

key default what
edit “Alt r” edit this tab: unlock, change, save or put back
new_tab “Alt n” a new tab (the + menu) in the pane’s folder
note “Alt j” a note or todo from here, tagged with the tab you’re in
zoom “Alt z” the focused pane takes the whole tab
leave “Alt x” leave the deck (Ctrl-q always does too)
controls “Alt g” take and give back zellij’s controls
tabs “Alt” this + 1..9 goes to a tab (“Alt”, “Ctrl Alt”, “Ctrl Shift”)
panes “Alt” this + arrows moves between panes

Where the shared notebook lives. Without this table it’s private, at ~/.local/share/phosphor/notes.md. phosphor init asks (offering folders with .obsidian/), and phosphor setup changes it later; either way, whatever notebook you already had moves into the new folder, never overwriting one that’s already there.

key what
folder a folder you already sync (Obsidian, Syncthing, git); notes.md goes inside it

Phosphor syncs nothing itself: the folder reaches other machines however you already sync it. The notebook stays one file (notes.md, plus notes-archive.md), so it shows in Obsidian as a single long note, not one note per entry. Editing it from Obsidian and from phosphor note/notes at the same moment isn’t merged: whichever save lands last wins, same as any shared file edited from two places at once. Across machines, a real conflict (both edited while offline from each other) becomes whatever conflict file your sync tool makes – Phosphor doesn’t resolve it.

key what
prepare a command that writes briefing notes; the notification comes on stdin
by the name shown in WORK NOTES
prompt replaces the default briefing prompt ({sender}, {message})
timeout seconds before giving up (default 600)

For phosphor prom (and “prometheus” in the + menu, which shows only with this section). One card per gauge, colored by its thresholds; the error, if a query fails, shows under it.

key default what
url http://localhost:9090 your Prometheus
interval 5 seconds between redraws
[[prometheus.gauges]]
name = "Memory alloc"
type = "gauge" # gauge (a bar), arc, sparkline
query = "go_memstats_alloc_bytes / 1024 / 1024"
min = 0
max = 256
warn = 128 # from here the card turns amber
crit = 200 # and red
unit = "MB"

A query answers with its first series. Without gauges it shows Prometheus watching itself:

phosphor prom with no gauges configured: Prometheus watching itself – up, TSDB head series, memory, a request-rate sparkline

For phosphor ci (and “ci” in the + menu, which shows only with this section). Status cards for GitHub Actions and GitLab pipelines: the latest run with its jobs, and under it a history of the five before it (so an idle repo still tells you what happened last). If a refresh fails the card stays, marked “stale”, instead of turning red; a real error says what to do (a private project needs glab auth login or GITLAB_TOKEN).

key default what
interval 10 seconds between redraws
[[ci.pipelines]]
name = "Frontend Build"
provider = "github" # github or gitlab
repo = "owner/repo"
branch = "main"
token = "env:GITHUB_TOKEN" # optional token or env var reference

Voice announcements for notifications (phosphor notify, and anything that calls it: a fleet host going down or coming back, a chat mention or DM).

key default what
enabled false speak notifications aloud
voice “glados” voice profile: glados, adjutant, hal, synth, system
glados_path “~/.local/share/phosphor/glados-tts” path to GLaDOS-TTS installation
volume 1.0 volume (0.0 to 1.0)
fleet_alerts false also announce fleet health alerts

fleet_alerts gates fleet health on top of enabled: with it off (the default), a host going down or coming back still shows in SYS and pushes to your phone if [push] is on, it just isn’t spoken – most people want a message meant for them read aloud, not every blip of a machine nobody’s looking at.

Notifications on a phone that isn’t attached (phosphor notify sends them, and so does anything that calls it: a fleet host going down or coming back – at most one push a minute per host, even if the link flaps – and a chat mention or DM). The brain is often in a rack with nobody near it, so speaking ([tts]) reaches no one; this sends the notice to an ntfy topic, and the ntfy app on the phone rings and vibrates, whether Termux is open or not. Off until you turn it on: the message text leaves the brain.

key default what
enabled false push every phosphor notify
url https://ntfy.sh the ntfy server: the public one, or your own (reachable from the brain)
topic “” the topic to publish on; subscribe to the same one in the phone’s ntfy app
token “” for a protected topic: the token, or env:VAR to read it from the environment
priority “default” min, low, default, high, max (high and max make the phone sound loudly)
title “phosphor” the notice’s title; the tab it came from is appended
[push]
enabled = true
topic = "deck-9f3k2x7q" # anyone who knows a public topic can read it: make it long and random

On the public ntfy.sh a topic is only as private as its name; with your own server (or a token) it’s yours. Try it with phosphor notify --push "hello": it says why if it can’t send.

ntfy is one Go binary; no Firebase, no account. Download a release for the brain’s architecture and run it:

./ntfy serve --listen-http <brain's tailscale IP>:8080 --cache-file ~/.local/share/ntfy/cache.db

Point [push] url at http://<that IP>:8080, and the phone’s ntfy app at the same address instead of ntfy.sh (its server field, not just the topic). Plain HTTP is fine over a tailnet: tailscale already encrypts the link, so there’s no certificate to manage. This is one process, not a phosphor service – keep it running yourself (a systemd --user unit is the usual way) so it’s still there after a reboot; phosphor only needs it reachable at url when phosphor notify --push runs.

For phosphor services, the SYS panel that lists systemd units and their state. Phosphor’s own units (the deck’s service and watchdog timer, one fleet-*.service per mounted host, one per [[tunnels]]) always show up, straight from what phosphor gen actually writes – nothing to configure for those. extra adds your own homelab services.

key default what
interval 5 seconds between redraws
extra [] your own units, by name (system scope; prefix "user:" for one of yours)
[services]
extra = ["nginx.service", "postgresql.service", "user:some-timer.timer"]

A system unit’s status is read without sudo (systemctl show, read-only); a missing or misspelled name shows as “not found” instead of failing the panel. inactive isn’t shown as a problem – a tunnel you turned off is supposed to sit there – only failed or a unit that doesn’t exist gets a loud color.

Programs of your own, next to the profile (~/.config/phosphor/apps.toml, override: PHOSPHOR_APPS). They show first in phosphor store as “yours” and in the + menu, and open in a tab of their own.

[[apps]]
name = "lazygit"
cmd = "lazygit"
desc = "git in a TUI"
key what
name its name in the store and the + menu
cmd the program: a name in your PATH, or a path (~ works)
args its arguments
desc a line for the store
alt start on the alternate screen
needs_size wait a second before starting