From dd61d4a75b0bde9cdbcb9d1aea23ed28cd1dbd8b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 9 Aug 2026 13:57:36 +0200 Subject: [PATCH] Ship herdr alongside tmux (#6406) * Ship herdr with a config that mirrors our tmux setup Installs herdr through the mise shim, ships the matching config as an Omarchy default, and adds the usual refresh/restart pair. The keybindings map tmux sessions to workspaces, windows to tabs, and keep both the prefix and direct bindings from config/tmux/tmux.conf. Co-Authored-By: Claude Opus 5 (1M context) * Add herdr versions of the tmux dev layout functions hdl, hds, hdlm, and hsl drive herdr through its socket API instead of tmux. hsl tiles into a real grid since herdr has no select-layout tiled. Co-Authored-By: Claude Opus 5 (1M context) * Namespace the herdr layout helpers so they stay out of the shell Co-Authored-By: Claude Opus 5 (1M context) * Create hdlm's tabs in its own workspace instead of the focused one herdr tab create follows the focused workspace without --workspace, so switching workspaces while hdlm loops scatters the new tabs. Co-Authored-By: Claude Opus 5 (1M context) * Lay hsl's grid out in visual order Splitting the first column repeatedly inserted each new column between it and the previous one, so uneven counts put the spare row in a middle column instead of the last. Co-Authored-By: Claude Opus 5 (1M context) * Report herdr config reload failures instead of swallowing them Co-Authored-By: Claude Opus 5 (1M context) * Hide herdr's pane scrollbars to match tmux Co-Authored-By: Claude Opus 5 (1M context) * Escape queued herdr layout commands * Install herdr from the omarchy-herdr package instead of mise * Use native herdr resize keybindings for tmux-style pane resizing * Rename the omarchy-herdr package to herdr --------- Co-authored-by: Claude Opus 5 (1M context) --- bin/omarchy-refresh-herdr | 6 ++ bin/omarchy-restart-herdr | 13 +++ config/herdr/config.toml | 82 +++++++++++++++ default/bash/aliases | 1 + default/bash/fns/herdr | 151 +++++++++++++++++++++++++++ install/omarchy-base.packages | 1 + migrations/1786273938.sh | 19 ++++ test/shell.d/herdr-functions-test.sh | 48 +++++++++ 8 files changed, 321 insertions(+) create mode 100755 bin/omarchy-refresh-herdr create mode 100755 bin/omarchy-restart-herdr create mode 100644 config/herdr/config.toml create mode 100644 default/bash/fns/herdr create mode 100644 migrations/1786273938.sh create mode 100644 test/shell.d/herdr-functions-test.sh diff --git a/bin/omarchy-refresh-herdr b/bin/omarchy-refresh-herdr new file mode 100755 index 00000000..1e94f1bd --- /dev/null +++ b/bin/omarchy-refresh-herdr @@ -0,0 +1,6 @@ +#!/bin/bash + +# omarchy:summary=Overwrite the user herdr config with the Omarchy default and reload herdr. + +omarchy-refresh-config herdr/config.toml +omarchy-restart-herdr diff --git a/bin/omarchy-restart-herdr b/bin/omarchy-restart-herdr new file mode 100755 index 00000000..0b045da3 --- /dev/null +++ b/bin/omarchy-restart-herdr @@ -0,0 +1,13 @@ +#!/bin/bash + +# omarchy:summary=Reload herdr if running with the latest configuration + +[[ $(herdr status server --json 2>/dev/null | jq -r '.running') == "true" ]] || exit 0 + +reload=$(herdr server reload-config) + +jq -r '.result.diagnostics[]?, (.error.message | select(. != null))' <<<"$reload" >&2 + +if [[ $(jq -r '.result.status // "failed"' <<<"$reload") == "failed" ]]; then + exit 1 +fi diff --git a/config/herdr/config.toml b/config/herdr/config.toml new file mode 100644 index 00000000..5bde7d4d --- /dev/null +++ b/config/herdr/config.toml @@ -0,0 +1,82 @@ +# Mirrors the Omarchy tmux config in config/tmux/tmux.conf +# tmux session -> herdr workspace, tmux window -> herdr tab, tmux pane -> herdr pane + +[theme] +# tmux ran on the terminal's own palette (bg=default, fg=default, ANSI blue accents) +name = "terminal" + +[theme.custom] +# The active tab is drawn as panel_bg text on an accent background, so panel_bg +# has to be dark for it to read - same colors as status-left's "#[fg=black,bg=blue]" +panel_bg = "black" + +[terminal] +# Matches -c "#{pane_current_path}" on every split, window, and session +new_cwd = "follow" + +[keys] +prefix = "ctrl+space" + +# Config and help +reload_config = "prefix+q" +help = "prefix+?" +detach = "prefix+d" + +# Copy mode +copy_mode = "prefix+[" + +# Panes +split_horizontal = ["prefix+h", "alt+enter"] +split_vertical = ["prefix+v", "alt+shift+enter"] +close_pane = ["prefix+x", "alt+esc"] +zoom = "prefix+z" +last_pane = "prefix+;" + +focus_pane_left = "ctrl+alt+left" +focus_pane_down = "ctrl+alt+down" +focus_pane_up = "ctrl+alt+up" +focus_pane_right = "ctrl+alt+right" + +resize_mode = ["prefix+ctrl+left", "prefix+ctrl+down", "prefix+ctrl+up", "prefix+ctrl+right"] + +# Like resize-pane on C-M-S-arrows +resize_pane_left = "ctrl+alt+shift+left" +resize_pane_down = "ctrl+alt+shift+down" +resize_pane_up = "ctrl+alt+shift+up" +resize_pane_right = "ctrl+alt+shift+right" + +# No tmux equivalent; herdr's default prefix+shift+p is taken by previous session +rename_pane = "prefix+shift+o" + +# Windows -> tabs +new_tab = "prefix+c" +rename_tab = "prefix+r" +close_tab = "prefix+k" +switch_tab = ["prefix+1..9", "alt+1..9"] +previous_tab = ["prefix+p", "alt+left"] +next_tab = ["prefix+n", "alt+right"] + +# Sessions -> workspaces +new_workspace = "prefix+shift+c" +rename_workspace = "prefix+shift+r" +close_workspace = "prefix+shift+k" +previous_workspace = ["prefix+shift+p", "alt+up"] +next_workspace = ["prefix+shift+n", "alt+down"] + +[ui] +accent = "blue" + +# tmux drew single-line dividers between adjacent panes +pane_gaps = false + +# tmux had no scrollbar column beside its panes +pane_scrollbars = false + +# kill-window and kill-session never asked +confirm_close = false + +# automatic-rename gave windows a name without prompting +prompt_new_tab_name = false + +# set -g mouse on +mouse_capture = true diff --git a/default/bash/aliases b/default/bash/aliases index bc44e0bf..e9630a24 100644 --- a/default/bash/aliases +++ b/default/bash/aliases @@ -50,6 +50,7 @@ alias cy='codex -s danger-full-access -a never' alias d='docker' alias r='rails' alias t='tmux attach || tmux new -s Work' +alias h='herdr' alias ic='tdl c' alias ix='tdl cx' alias icx='tdl c cx' diff --git a/default/bash/fns/herdr b/default/bash/fns/herdr new file mode 100644 index 00000000..3a18527b --- /dev/null +++ b/default/bash/fns/herdr @@ -0,0 +1,151 @@ +# Echo a split ratio as a float +# Usage: _herdr_ratio +_herdr_ratio() { + awk -v a="$1" -v b="$2" 'BEGIN { printf "%.4f", a / b }' +} + +# Split a herdr pane and echo the id of the new pane +# Usage: _herdr_split +_herdr_split() { + herdr pane split "$1" --direction "$2" --ratio "$3" --cwd "$4" --no-focus | + jq -r '.result.pane.pane_id' +} + +# Create a Herdr Dev Layout with editor, ai, and terminal +# Usage: hdl [] +hdl() { + [[ -z $1 ]] && { echo "Usage: hdl []"; return 1; } + [[ -z $HERDR_PANE_ID ]] && { echo "You must start herdr to use hdl."; return 1; } + + local current_dir="${PWD}" + local editor_pane ai_pane ai2_pane + local ai="$1" + local ai2="${2:-}" + + # Use HERDR_PANE_ID for the pane we're running in (stable even if focus moves) + editor_pane="$HERDR_PANE_ID" + + # Name the current tab after the base directory name + herdr tab rename "$HERDR_TAB_ID" "$(basename "$current_dir")" >/dev/null + + # Split tab vertically - top 85%, bottom 15% + _herdr_split "$editor_pane" down 0.85 "$current_dir" >/dev/null + + # Split editor pane horizontally - AI on right 30% + ai_pane=$(_herdr_split "$editor_pane" right 0.7 "$current_dir") + + # If second AI provided, split the AI pane vertically + if [[ -n $ai2 ]]; then + ai2_pane=$(_herdr_split "$ai_pane" down 0.5 "$current_dir") + herdr pane run "$ai2_pane" "$ai2" >/dev/null + fi + + # Run ai in the right pane + herdr pane run "$ai_pane" "$ai" >/dev/null + + # Run nvim in the left pane + herdr pane run "$editor_pane" "$EDITOR ." >/dev/null +} + +# Create a Herdr Dev Square layout with editor, diff watch, terminal, and opencode +# Usage: hds +hds() { + [[ -n $1 ]] && { echo "Usage: hds"; return 1; } + [[ -z $HERDR_PANE_ID ]] && { echo "You must start herdr to use hds."; return 1; } + + local current_dir="${PWD}" + local editor_pane diff_pane terminal_pane opencode_pane + + editor_pane="$HERDR_PANE_ID" + + herdr tab rename "$HERDR_TAB_ID" "$(basename "$current_dir")" >/dev/null + + terminal_pane=$(_herdr_split "$editor_pane" down 0.5 "$current_dir") + diff_pane=$(_herdr_split "$editor_pane" right 0.5 "$current_dir") + opencode_pane=$(_herdr_split "$terminal_pane" right 0.5 "$current_dir") + + herdr pane run "$editor_pane" "nvim ." >/dev/null + herdr pane run "$diff_pane" "hunk diff --watch" >/dev/null + herdr pane run "$opencode_pane" "opencode" >/dev/null +} + +# Create multiple hdl tabs with one per subdirectory in the current directory +# Usage: hdlm [] +hdlm() { + [[ -z $1 ]] && { echo "Usage: hdlm []"; return 1; } + [[ -z $HERDR_PANE_ID ]] && { echo "You must start herdr to use hdlm."; return 1; } + + local ai="$1" + local ai2="${2:-}" + local base_dir="$PWD" + local first=true + local hdl_command + + # Rename the workspace to the current directory name + herdr workspace rename "$HERDR_WORKSPACE_ID" "$(basename "$base_dir")" >/dev/null + + for dir in "$base_dir"/*/; do + [[ -d $dir ]] || continue + local dirpath="${dir%/}" + + printf -v hdl_command 'hdl %q' "$ai" + [[ -n $ai2 ]] && printf -v hdl_command '%s %q' "$hdl_command" "$ai2" + + if $first; then + # Reuse the current tab for the first project + printf -v hdl_command 'cd %q && %s' "$dirpath" "$hdl_command" + herdr pane run "$HERDR_PANE_ID" "$hdl_command" >/dev/null + first=false + else + local pane_id + pane_id=$(herdr tab create --workspace "$HERDR_WORKSPACE_ID" --cwd "$dirpath" --no-focus | + jq -r '.result.root_pane.pane_id') + herdr pane run "$pane_id" "$hdl_command" >/dev/null + fi + done +} + +# Create a multi-pane swarm layout with the same command started in each pane (great for AI) +# Usage: hsl +hsl() { + [[ -z $1 || -z $2 ]] && { echo "Usage: hsl "; return 1; } + [[ -z $HERDR_PANE_ID ]] && { echo "You must start herdr to use hsl."; return 1; } + + local count="$1" + local cmd="$2" + local current_dir="${PWD}" + local -a columns panes + + herdr tab rename "$HERDR_TAB_ID" "$(basename "$current_dir")" >/dev/null + + # Tile into a grid: ceil(sqrt(count)) columns, rows spread across them + local cols=1 + while (( cols * cols < count )); do ((cols++)); done + + # Even columns come from splitting the rightmost one off at 1/(n-k+1) each time, + # which keeps the array in left-to-right order + columns=("$HERDR_PANE_ID") + local k + for (( k = 1; k < cols; k++ )); do + columns+=("$(_herdr_split "${columns[-1]}" right "$(_herdr_ratio 1 $((cols - k + 1)))" "$current_dir")") + done + + # Split each column into its share of rows, again evenly and top-to-bottom + local col index rows j last + for (( index = 0; index < cols; index++ )); do + col="${columns[index]}" + rows=$(( count / cols )) + (( index < count % cols )) && (( rows++ )) + panes+=("$col") + last="$col" + for (( j = 1; j < rows; j++ )); do + last=$(_herdr_split "$last" down "$(_herdr_ratio 1 $((rows - j + 1)))" "$current_dir") + panes+=("$last") + done + done + + local pane + for pane in "${panes[@]}"; do + herdr pane run "$pane" "$cmd" >/dev/null + done +} diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 9a1863ec..2244462a 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -50,6 +50,7 @@ gum gvfs-mtp gvfs-nfs gvfs-smb +herdr hyprland hyprland-guiutils hyprland-preview-share-picker diff --git a/migrations/1786273938.sh b/migrations/1786273938.sh new file mode 100644 index 00000000..5e161a19 --- /dev/null +++ b/migrations/1786273938.sh @@ -0,0 +1,19 @@ +echo "Install herdr from the Omarchy package repo and seed its config" + +# The package was briefly published as omarchy-herdr; herdr replaces it +omarchy-pkg-drop omarchy-herdr +omarchy-pkg-add herdr + +# An earlier revision of this migration installed herdr through mise. Drop that +# install so a stale client can't shadow the packaged /usr/bin/herdr with an +# older wire protocol. +rm -f "$HOME/.local/bin/herdr" +if mise ls herdr 2>/dev/null | grep -q herdr; then + mise unuse -g herdr &>/dev/null || true + mise uninstall -a herdr &>/dev/null || true +fi +rm -rf "$HOME/.local/share/mise/installs/herdr" "$HOME/.local/share/mise/shims/herdr" + +# Only seed. A user who already has a herdr config keeps it; omarchy-refresh-herdr +# is the explicit way to take the shipped defaults. +[[ -f "$HOME/.config/herdr/config.toml" ]] || omarchy-refresh-config herdr/config.toml diff --git a/test/shell.d/herdr-functions-test.sh b/test/shell.d/herdr-functions-test.sh new file mode 100644 index 00000000..cd567818 --- /dev/null +++ b/test/shell.d/herdr-functions-test.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" +source "$ROOT/default/bash/fns/herdr" + +test_dir=$(mktemp -d) +trap 'rm -rf -- "$test_dir"' EXIT + +mkdir -p "$test_dir/first project's files" "$test_dir/second project" +log="$test_dir/herdr.log" + +herdr() { + if [[ $1 == "workspace" ]]; then + return + elif [[ $1 == "tab" ]]; then + printf '{"result":{"root_pane":{"pane_id":"new-pane"}}}\n' + elif [[ $1 == "pane" && $2 == "run" ]]; then + printf '%s\n' "$4" >>"$log" + fi +} + +export HERDR_PANE_ID="current-pane" +export HERDR_WORKSPACE_ID="workspace" + +cd "$test_dir" +hdlm "codex --flag='value with spaces'" "claude --model sonnet" + +mapfile -t commands <"$log" + +expected_first=$(printf 'cd %q && hdl %q %q' \ + "$test_dir/first project's files" "codex --flag='value with spaces'" "claude --model sonnet") +expected_second=$(printf 'hdl %q %q' "codex --flag='value with spaces'" "claude --model sonnet") + +[[ ${commands[0]} == "$expected_first" ]] || fail "hdlm safely quotes its first queued command" +pass "hdlm safely quotes its first queued command" + +[[ ${commands[1]} == "$expected_second" ]] || fail "hdlm preserves agent argument boundaries" +pass "hdlm preserves agent argument boundaries" + +: >"$log" +hdlm "codex --quiet" +mapfile -t commands <"$log" +expected_first=$(printf 'cd %q && hdl %q' "$test_dir/first project's files" "codex --quiet") + +[[ ${commands[0]} == "$expected_first" ]] || fail "hdlm omits an absent second agent argument" +pass "hdlm omits an absent second agent argument"