herdr-layout — declarative workspace layouts for herdr
zellij has zellij --layout=foo.kdl. tmux has tmuxinator. herdr has no documented
equivalent — but it turns out the engine is already there, it's just not wired
up to the CLI.
herdr's socket API exposes three layout methods that appear in neither
herdr --help nor the docs at herdr.dev:
| method | what it does |
|---|---|
layout.apply |
build an entire tab from a declarative tree, in one request |
layout.export |
dump an existing tab back out as that same tree |
layout.set_split_ratio |
adjust a split |
herdr-layout is a ~120-line shell wrapper over layout.apply / layout.export.
That's all it is — herdr does the actual work.
Verified against herdr 0.8.0, protocol 19.
You can build a layout with herdr pane split in a loop. It's worse in four ways:
- Split order is a puzzle. Panes form a binary tree, so a 2×2 grid needs split-down-then-right-on-each-row in exactly the right sequence. A declarative tree just says what you want.
pane splithas no--command. You split, thenherdr pane run <id> '...'types the command into the pane's shell — which means sleeping until the prompt is ready and hoping you win the race.layout.applylaunches the command as the pane's process, no shell wrapper, no race. (Confirmed: the pane'sshell_pidis the command's pid.)- One request per tab instead of N splits + N renames, each parsed with
jq. layout.exportmeans you never hand-write a layout. Drag panes around in the TUI until it looks right, then dump it to a file.
- herdr ≥ 0.8.0 running (
herdr status server) jq— 1.5 or newer (the script defineswalk/1itself, since 1.5 lacks it)ncwith unix-socket support (-U) — OpenBSD netcat, the default on Ubuntu/Debian/macOS. GNU netcat (nc.traditional) will not work.
No runtime, no build step, no package manager.
# check your nc
printf '{"id":"x","method":"ping","params":{}}' | nc -U ~/.config/herdr/herdr.sock
# → {"id":"x","result":{"type":"pong","version":"0.8.0",...}}curl -o ~/bin/herdr-layout https://gist.githubusercontent.com/fukata/de3a182e0bba7b49f588054259e33f0c/raw/herdr-layout
chmod +x ~/bin/herdr-layoutherdr-layout <layout.json> # build the workspace
herdr-layout <layout.json> --replace # tear down an existing one first
herdr-layout <layout.json> --no-commands # build the shape, run nothing
herdr-layout --export # dump the focused workspace
herdr-layout --export --workspace w3 # dump a specific one| flag | effect |
|---|---|
--replace |
close any existing workspace with the same label, then rebuild |
--no-commands |
strip every command — panes open as plain shells. Good for checking geometry before you let daemons loose |
--export |
print the workspace as a layout file on stdout |
--workspace <id> |
target for --export (default: $HERDR_WORKSPACE_ID, else the focused one) |
--socket <path> |
non-default server socket (also $HERDR_SOCKET) |
Don't write the JSON by hand. Build it in the TUI, then:
herdr-layout --export > ~/.config/herdr/layouts/myapp.jsonPaths under $HOME are rewritten back to ~/ on export and expanded again on
apply, so one file works across machines — handy if you sync your dotfiles.
A node is either a pane (leaf) or a split (branch):
// leaf
{
"type": "pane",
"label": "server", // pane title
"cwd": "~/src/myapp", // per-pane — splits across repos are fine
"command": ["npm", "run", "dev"], // argv, NOT a shell string. Omit for a shell.
"env": { "PORT": "3000" }
}
// branch — always exactly two children
{
"type": "split",
"direction": "right", // "right" = side by side | "down" = stacked
"ratio": 0.5, // 0–1, share given to "first"
"first": { /* node */ },
"second": { /* node */ }
}Every field except type (and a split's direction/ratio/first/second) is
optional.
"command": ["npm", "run", "dev"] // ✅
"command": ["npm run dev"] // ❌ looks for a binary named "npm run dev"
"command": ["bash", "-lc", "a && b"] // ✅ when you really want a shellThe process is launched directly in the pane — exactly like zellij's
command/args. When it exits, the pane closes.
Splits are binary, so a grid is a split of splits. This gives a tall left pane with two stacked on the right, and a full-width row beneath:
{
"type": "split", "direction": "down", "ratio": 0.5,
"first": {
"type": "split", "direction": "right", "ratio": 0.5,
"first": { "type": "pane", "label": "a" },
"second": {
"type": "split", "direction": "down", "ratio": 0.5,
"first": { "type": "pane", "label": "b" },
"second": { "type": "pane", "label": "c" }
}
},
"second": {
"type": "split", "direction": "right", "ratio": 0.5,
"first": { "type": "pane", "label": "d" },
"second": { "type": "pane", "label": "e" }
}
}┌─────────┬─────────┐
│ │ b │
│ a ├─────────┤
│ │ c │
├─────────┴─────────┤
│ d │ e │
└─────────┴─────────┘
See example.json for a complete file.
A peco picker, in the spirit of zellij --layout=$(find ... | peco):
function h() {
local layout
layout=$(find ~/.config/herdr/layouts -type f -name '*.json' | sort | peco) || return 1
[ -n "$layout" ] || return 1
# the API needs a server; start one headless if there isn't one
if ! herdr status server 2>/dev/null | grep -q '^status: running'; then
nohup herdr server >/dev/null 2>&1 &
local i; for i in $(seq 50); do
herdr status server 2>/dev/null | grep -q '^status: running' && break
sleep 0.1
done
fi
herdr-layout "$layout" "$@" || return $?
[ "${HERDR_ENV:-}" = 1 ] || herdr # attach only if we're outside herdr
}Works from a cold boot — no need to run herdr first.
layout.apply replaces the tab and returns a new tab_id. Apply to
w1:t1 and you get back w1:t3. Read it from .result.layout.tab_id; the old id
is gone. This is the one thing that will silently break a hand-rolled script.
herdr restores your session on restart. session.json persists workspaces,
tabs, panes and cwd — so after a reboot your layout comes back shaped but
dead: commands are not stored, so nothing is running. A second
herdr-layout foo.json will then refuse ("already exists"). That's what
--replace is for.
tab.focused lies when the workspace isn't focused. It reflects live UI
state, so every tab reports false from a background workspace. Use
workspace.get → active_tab_id instead. --export already does.
A cwd that doesn't exist fails silently. The pane opens in $HOME instead,
and layout.apply still reports success. Typo a path and you get a workspace that
looks right and is entirely wrong — check with
herdr pane list --workspace <id> | jq -r '.result.panes[].cwd' after building.
snap-installed jq can't read /tmp. Confinement. $HOME and /mnt/...
are fine. The script pipes layout files through stdin to sidestep it entirely.
One request per connection. The server closes the socket after each response,
so each call is its own nc. Convenient — nc exits on its own, no -w/-q
timeout hacks.
SOCK=~/.config/herdr/herdr.sock
# every request: {"id": ..., "method": ..., "params": {...}}
printf '{"id":"x","method":"workspace.list","params":{}}' | nc -U $SOCK | jq
# the full schema, including every layout type
herdr api schema --json | jq '.schemas.request["$defs"].LayoutNode'herdr api schema --json is the authority — it documents far more than the CLI
surfaces. LayoutNode, LayoutApplyParams and LayoutExportParams are all in
there.
Public domain / CC0. It's a wrapper around someone else's good idea.
{ "label": "myapp", // workspace name; also the --replace key "cwd": "~/src/myapp", // default cwd for the workspace "tabs": [ { "label": "code", // tab title "focus": true, // optional; this tab is active after building "root": { /* node */ } } ] }