Skip to content

Instantly share code, notes, and snippets.

@andynu
Created August 14, 2026 12:47
Show Gist options
  • Select an option

  • Save andynu/c40080acab23343cbf608b9dbd1b1756 to your computer and use it in GitHub Desktop.

Select an option

Save andynu/c40080acab23343cbf608b9dbd1b1756 to your computer and use it in GitHub Desktop.
herdr-project: a prefix+f fzf project picker for herdr that opens or focuses a space per project (ported from a tmux display-popup binding)

herdr-project — a prefix+f fzf project picker for herdr

herdr is a terminal workspace manager for AI coding agents (0.8.0 at time of writing). It has spaces, tabs, and panes, but no session-per-project layer the way tmux does, so "switch to project X" isn't a built-in. This is that binding.

Hit ctrl+a f, get an fzf popup listing every directory under ~/work/src and ~/projects, pick one, and land in a herdr space labelled with the project's basename whose first tab starts in that directory. If a space with that label is already open, it focuses that one instead of creating a second.

project> rails
  work: brainarray
  work: rails-upgrade-notes
> proj: railsconf-talk
  proj: dotfiles

Files

File Where it goes
herdr-project anywhere on PATH (~/bin/herdr-project), chmod +x
config.toml the [[keys.command]] block goes in ~/.config/herdr/config.toml
tmux-original.conf the tmux binding this was ported from, for reference

Requires herdr, fzf, and jq.

Install

install -m 755 herdr-project ~/bin/herdr-project
# paste the [[keys.command]] block into ~/.config/herdr/config.toml
herdr config check && herdr server reload-config

Edit WORK_ROOT and PROJ_ROOT at the top of the script to match wherever your projects actually live. Two roots is arbitrary; add or drop cases in list_projects and the case statement below it in matching pairs.

Things that bit me

The [[keys.command]] array-of-tables has to be last in [keys]. TOML scoping, not a herdr quirk: any plain key = value line placed after it parses as part of keys.command rather than keys. herdr config check catches it, and the error points at the wrong line.

Bare width/height numbers are terminal cells, chosen to match tmux's display-popup -w 60 -h 20. If you want a percentage it has to be a quoted string, width = "80%".

fzf exits 130 when you press Esc. Under set -e that turns "never mind" into a failed command and an error in the popup, so the pick is written as choice=$(...) || exit 0.

The workspace lookup avoids first/1. [.result.workspaces[] | select(...)] | .[0] // empty does the same job as first(...) and works on jq builds that don't implement it.

Labels are directory basenames, so two projects sharing a basename across the two roots also share a space. The work: / proj: prefixes exist only to disambiguate the fzf list; they're stripped before the path is rebuilt.

The tmux version

The original binding did the same pick inline in tmux.conf and ended in new-session -d -s "$name" -c "$path" + switch-client. That works because a tmux session is a first-class named thing you can create detached and jump to. herdr's equivalent target is a space, created with herdr workspace create --cwd <path> --label <basename> --focus, and there's no "create detached, switch separately" split to worry about. The whole thing outgrew a quoted one-liner in a config file, hence the script.

# ~/.config/herdr/config.toml — the relevant excerpt.
# Validate after editing: herdr config check
# Apply to running server: herdr server reload-config
[keys]
prefix = "ctrl+a"
# ... your other [keys] settings go HERE, above the [[keys.command]] block ...
# NOTE: this array-of-tables must stay LAST in [keys] — any plain key = value
# line after it would parse as part of keys.command, not keys.
[[keys.command]]
key = "prefix+f"
type = "popup"
command = "herdr-project"
# Bare numbers are terminal cells (matching tmux's -w 60 -h 20);
# a quoted string must be a percentage like "80%".
width = 60
height = 20
#!/bin/bash
# herdr-project - fzf project picker that opens or focuses a herdr space
#
# Usage:
# herdr-project # fzf over ~/work/src and ~/projects
#
# The herdr equivalent of the tmux `bind-key f display-popup` project switcher
# (see tmux-original.conf). Picking a project focuses its existing space if one
# is already open, otherwise creates a space labelled with the directory
# basename whose first tab starts in that directory.
#
# Bound to C-a f via [[keys.command]] in ~/.config/herdr/config.toml.
set -euo pipefail
for dep in herdr jq fzf; do
if ! command -v "$dep" >/dev/null 2>&1; then
echo "Error: $dep is not installed" >&2
exit 1
fi
done
WORK_ROOT="$HOME/work/src"
PROJ_ROOT="$HOME/projects"
# Prefixes disambiguate same-named dirs across the two roots, and are stripped
# back off before the path is rebuilt below.
list_projects() {
local d
shopt -s nullglob
for d in "$WORK_ROOT"/*/; do
d="${d%/}"
printf 'work: %s\n' "${d##*/}"
done
for d in "$PROJ_ROOT"/*/; do
d="${d%/}"
printf 'proj: %s\n' "${d##*/}"
done
}
# fzf exits 130 on Esc; that is a cancel, not a failure.
choice=$(list_projects | fzf --reverse --prompt="project> ") || exit 0
[ -n "$choice" ] || exit 0
name="${choice#*: }"
case "$choice" in
"work: "*) path="$WORK_ROOT/$name" ;;
"proj: "*) path="$PROJ_ROOT/$name" ;;
*)
echo "Error: unrecognized choice: $choice" >&2
exit 1
;;
esac
if [ ! -d "$path" ]; then
echo "Error: no such directory: $path" >&2
exit 1
fi
# Array-index rather than first/1, which some jq builds do not implement.
existing=$(herdr workspace list 2>/dev/null |
jq -r --arg label "$name" \
'[.result.workspaces[] | select(.label == $label) | .workspace_id] | .[0] // empty')
if [ -n "$existing" ]; then
herdr workspace focus "$existing" >/dev/null
else
herdr workspace create --cwd "$path" --label "$name" --focus >/dev/null
fi
# The tmux binding herdr-project was ported from, kept for reference.
# A tmux session is a named, createable-while-detached object, so the whole
# switcher fits in one quoted display-popup command.
# fzf project switcher: pick a ~/work/src/ project, create/switch to its session
bind-key f display-popup -E -w 60 -h 20 '\
choice=$( \
{ ls -d ~/work/src/*/ 2>/dev/null | sed "s|$HOME/work/src/|work: |;s|/$||"; \
ls -d ~/projects/*/ 2>/dev/null | sed "s|$HOME/projects/|proj: |;s|/$||"; \
} | fzf --reverse --prompt="project> " \
) && [ -n "$choice" ] && \
name="${choice#*: }" && \
case "$choice" in \
work:*) path="$HOME/work/src/$name" ;; \
proj:*) path="$HOME/projects/$name" ;; \
esac && \
tmux new-session -d -s "$name" -c "$path" 2>/dev/null; \
tmux switch-client -t "$name"'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment