aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-05 11:27:00 +0300
committerIgor Soarez <igor@soarez.org>2026-10-05 11:27:00 +0300
commit986e760687a2d34051934d410bec71fa2efc349b (patch)
treef57dcc4ba0d7690fdf90eeb2f0970a8ebbefc45a
parent7b116c05bfda646af39d2524c54e70c751f57ee8 (diff)
tmuxrdr: tmux-mode plugin
The tmux default prefix bindings herdr has no built-in action for, as a herdr plugin built on its CLI and socket: break-pane, swap-pane and rotate-window by pane order (focus behaviour measured on tmux 3.7c), mark/clear-mark, last-window and switch-client -l (from focus events, per session), select-window and move-window by index, paste-buffer -p, display-message in tmux's default format, select-layout -E, kill-pane and kill-window behind tmux's y/n prompts, a command prompt taking tmux's commands, clock-mode, show-messages, and automatic-rename. Also a .gitignore for secrets scoped to tmuxrdr/, leaving herdr's own untouched.
-rw-r--r--tmuxrdr/.gitignore14
-rwxr-xr-xtmuxrdr/plugin/bin/automatic-rename37
-rwxr-xr-xtmuxrdr/plugin/bin/break-pane14
-rwxr-xr-xtmuxrdr/plugin/bin/clock-mode34
-rwxr-xr-xtmuxrdr/plugin/bin/command-prompt82
-rwxr-xr-xtmuxrdr/plugin/bin/confirm-kill20
-rwxr-xr-xtmuxrdr/plugin/bin/display-message15
-rwxr-xr-xtmuxrdr/plugin/bin/last25
-rwxr-xr-xtmuxrdr/plugin/bin/mark23
-rwxr-xr-xtmuxrdr/plugin/bin/move-window34
-rwxr-xr-xtmuxrdr/plugin/bin/paste-buffer20
-rwxr-xr-xtmuxrdr/plugin/bin/rotate-window34
-rwxr-xr-xtmuxrdr/plugin/bin/select-window22
-rwxr-xr-xtmuxrdr/plugin/bin/show-messages8
-rwxr-xr-xtmuxrdr/plugin/bin/spread-layout25
-rwxr-xr-xtmuxrdr/plugin/bin/swap-pane30
-rwxr-xr-xtmuxrdr/plugin/bin/track-focus36
-rw-r--r--tmuxrdr/plugin/herdr-plugin.toml92
-rw-r--r--tmuxrdr/plugin/lib.sh95
19 files changed, 660 insertions, 0 deletions
diff --git a/tmuxrdr/.gitignore b/tmuxrdr/.gitignore
new file mode 100644
index 00000000..bf84f707
--- /dev/null
+++ b/tmuxrdr/.gitignore
@@ -0,0 +1,14 @@
+# Secrets never belong in tmuxrdr. Kept here rather than in herdr's top-level
+# .gitignore, which tmuxrdr leaves untouched so rebases stay clean; the same
+# patterns cover the whole checkout through .git/info/exclude (local only).
+.env
+.env.*
+*.pem
+*.key
+*.crt
+*.p12
+*.pfx
+*.jks
+credentials.json
+token.json
+.DS_Store
diff --git a/tmuxrdr/plugin/bin/automatic-rename b/tmuxrdr/plugin/bin/automatic-rename
new file mode 100755
index 00000000..f3409a5d
--- /dev/null
+++ b/tmuxrdr/plugin/bin/automatic-rename
@@ -0,0 +1,37 @@
+#!/bin/sh
+# tmux.conf: automatic-rename on, automatic-rename-format '#{b:pane_current_path}',
+# status-interval 5 -- every window is named after the directory of its
+# current pane. Herdr names nothing (an unnamed tab shows its place in the tab
+# bar), so this runs as a tab_bar_right command entry every 5 s (config.toml)
+# and renames each tab after the basename of its focused pane's foreground
+# directory -- tmux's pane_current_path. Like tmux's rename-window, a manual
+# rename switches it off for that tab for good: a label this did not set is
+# left alone. Prints nothing, so the status entry stays empty.
+H="${HERDR_BIN_PATH:-herdr}"
+dir="${XDG_STATE_HOME:-$HOME/.local/state}/herdr-tmux-mode/$(basename "$(dirname "${HERDR_SOCKET_PATH:-x/default/x}")")/names"
+mkdir -p "$dir"
+
+"$H" api snapshot | jq -r '
+ .result.snapshot as $s
+ | $s.tabs as $tabs
+ | range(0; $tabs | length) as $i
+ | $tabs[$i] as $t
+ | ([$tabs[0:$i][] | select(.workspace_id == $t.workspace_id)] | length + 1) as $pos
+ | ([$s.layouts[] | select(.tab_id == $t.tab_id) | .focused_pane_id][0]) as $fp
+ | ([$s.panes[] | select(.pane_id == $fp) | (.foreground_cwd // .cwd // "")][0] // "") as $cwd
+ | select($cwd != "")
+ | [$t.tab_id, $t.label, ($pos | tostring),
+ (if $cwd == "/" then "/" else ($cwd | rtrimstr("/") | split("/") | last) end)]
+ | @tsv' \
+| while IFS="$(printf '\t')" read -r tab label pos want; do
+ f="$dir/$(printf '%s' "$tab" | tr -c 'A-Za-z0-9._-' '_')"
+ mine="$(cat "$f" 2>/dev/null || true)"
+ [ "$mine" = "@manual" ] && continue
+ if [ "$label" != "$mine" ] && [ "$label" != "$pos" ]; then
+ printf '@manual\n' >"$f"
+ continue
+ fi
+ [ "$label" = "$want" ] && continue
+ "$H" tab rename "$tab" "$want" >/dev/null 2>&1 && printf '%s\n' "$want" >"$f"
+done
+exit 0
diff --git a/tmuxrdr/plugin/bin/break-pane b/tmuxrdr/plugin/bin/break-pane
new file mode 100755
index 00000000..f2683c93
--- /dev/null
+++ b/tmuxrdr/plugin/bin/break-pane
@@ -0,0 +1,14 @@
+#!/bin/sh
+# tmux prefix+! (break-pane): the current pane becomes a tab of its own, at the
+# end of the tab bar, and that tab is selected. Same message as tmux when the
+# pane is alone.
+. "$(dirname "$0")/../lib.sh"
+
+p="$(active_pane)"
+[ "$(tab_panes "$p" | wc -l)" -gt 1 ] || fail "can't break with only one pane"
+tab="$("$H" pane move "$p" --new-tab --workspace "$(pane_info "$p" workspace_id)" \
+ --no-focus | jq -r '.result.move_result.pane.tab_id // empty')"
+[ -n "$tab" ] || fail "break-pane failed"
+# Focused separately: a move's own --focus emits no tab.focused event, so
+# track-focus would miss it and `last window` would not lead back.
+"$H" tab focus "$tab" >/dev/null
diff --git a/tmuxrdr/plugin/bin/clock-mode b/tmuxrdr/plugin/bin/clock-mode
new file mode 100755
index 00000000..220d53d3
--- /dev/null
+++ b/tmuxrdr/plugin/bin/clock-mode
@@ -0,0 +1,34 @@
+#!/bin/sh
+# tmux prefix+t (clock-mode): a big 24-hour clock in clock-mode-colour (blue),
+# centred, until any key. Run as a popup.
+trap 'stty "$tty"; printf "\033[0m\033[?25h"' EXIT
+tty="$(stty -g)"
+stty -icanon -echo min 0 time 10
+printf '\033[?25l'
+while :; do
+ set -- $(stty size)
+ printf '\033[H\033[2J\033[34m'
+ date '+%H:%M' | awk -v rows="$1" -v cols="$2" '
+ BEGIN {
+ f["0"] = "###|#.#|#.#|#.#|###"; f["1"] = "..#|..#|..#|..#|..#"
+ f["2"] = "###|..#|###|#..|###"; f["3"] = "###|..#|###|..#|###"
+ f["4"] = "#.#|#.#|###|..#|..#"; f["5"] = "###|#..|###|..#|###"
+ f["6"] = "###|#..|###|#.#|###"; f["7"] = "###|..#|..#|..#|..#"
+ f["8"] = "###|#.#|###|#.#|###"; f["9"] = "###|#.#|###|..#|###"
+ f[":"] = ".|#|.|#|."
+ }
+ {
+ for (r = 1; r <= 5; r++) line[r] = ""
+ for (i = 1; i <= length($0); i++) {
+ split(f[substr($0, i, 1)], g, "|")
+ for (r = 1; r <= 5; r++) line[r] = line[r] g[r] "."
+ }
+ w = length(line[1]) * 2
+ for (i = 1; i <= int((rows - 5) / 2); i++) print ""
+ for (r = 1; r <= 5; r++) {
+ s = line[r]; gsub(/#/, "██", s); gsub(/\./, " ", s)
+ printf "%" int((cols - w) / 2) "s%s\n", "", s
+ }
+ }'
+ [ -n "$(dd bs=1 count=1 2>/dev/null)" ] && break
+done
diff --git a/tmuxrdr/plugin/bin/command-prompt b/tmuxrdr/plugin/bin/command-prompt
new file mode 100755
index 00000000..8c4a4e4f
--- /dev/null
+++ b/tmuxrdr/plugin/bin/command-prompt
@@ -0,0 +1,82 @@
+#!/bin/sh
+# tmux prefix+: (command-prompt). Herdr has no command language of its own, so
+# this takes the tmux commands that have a herdr counterpart, with their usual
+# aliases and flags, and passes anything else to the herdr CLI -- `:pane list`
+# runs `herdr pane list` and shows the result. Run as a popup.
+. "$(dirname "$0")/../lib.sh"
+
+action() { "$H" plugin action invoke "tmuxrdr.tmux-mode.$1" >/dev/null; }
+pause() { printf '\n[press any key]'; read_key >/dev/null; }
+
+printf ':'
+IFS= read -r line || exit 0
+[ -n "$line" ] || exit 0
+eval "set -- $line" || { pause; exit 1; }
+cmd="$1"
+shift
+
+p="$(active_pane)"
+has() { case " $* " in *" $flag "*) return 0 ;; esac; return 1; }
+opt() { # opt -t ARGS...: the value after -t, if any
+ _f="$1"; shift
+ while [ $# -gt 0 ]; do
+ [ "$1" = "$_f" ] && { printf '%s' "${2:-}"; return; }
+ shift
+ done
+}
+
+# The case lives in a function: bash 3.2, which is macOS /bin/sh, misparses a
+# case statement written inside $(...) -- its pattern parentheses end the
+# substitution early.
+run() {
+ case "$cmd" in
+ kill-pane | killp) "$H" pane close "$p" ;;
+ kill-window | killw) "$H" tab close "$(pane_info "$p" tab_id)" ;;
+ kill-session) "$H" workspace close "$(pane_info "$p" workspace_id)" ;;
+ kill-server) "$H" server stop ;;
+ new-window | neww) "$H" tab create --workspace "$(pane_info "$p" workspace_id)" --focus ;;
+ split-window | splitw | new-pane | newp)
+ flag=-h; dir=down; has "$@" && dir=right
+ "$H" pane split "$p" --direction "$dir" --focus ;;
+ rename-window | renamew) "$H" tab rename "$(pane_info "$p" tab_id)" "$*" ;;
+ rename-session | rename) "$H" workspace rename "$(pane_info "$p" workspace_id)" "$*" ;;
+ select-window | selectw) t="$(opt -t "$@")"; t="${t#:}"; sh "$BIN/select-window" "${t#=}" ;;
+ break-pane | breakp) sh "$BIN/break-pane" ;;
+ move-window | movew) t="$(opt -t "$@")"; sh "$BIN/move-window" "${t#:}" ;;
+ swap-pane | swapp)
+ flag=-U; if has "$@"; then sh "$BIN/swap-pane" prev
+ else flag=-D; if has "$@"; then sh "$BIN/swap-pane" next; else action swap-marked; fi; fi ;;
+ rotate-window | rotatew) flag=-D; if has "$@"; then sh "$BIN/rotate-window" down; else sh "$BIN/rotate-window" up; fi ;;
+ last-window | last) action last-window ;;
+ switch-client | switchc) flag=-l; if has "$@"; then action last-session; else echo "switch-client: only -l is supported"; exit 1; fi ;;
+ select-pane | selectp)
+ flag=-m; if has "$@"; then action mark; exit; fi
+ flag=-M; if has "$@"; then action clear-mark; exit; fi
+ for d in L:left R:right U:up D:down; do
+ flag="-${d%%:*}"; has "$@" && { "$H" pane focus --direction "${d#*:}" --pane "$p"; exit; }
+ done
+ echo "select-pane: only -L -R -U -D -m -M are supported"; exit 1 ;;
+ resize-pane | resizep)
+ flag=-Z; has "$@" && { "$H" pane zoom "$p" --toggle; exit; }
+ n="$(printf '%s\n' "$@" | grep -x '[0-9][0-9]*' | head -n 1)"
+ for d in L:left R:right U:up D:down; do
+ flag="-${d%%:*}"; has "$@" && { "$H" pane resize --direction "${d#*:}" --amount "$(awk "BEGIN { print ${n:-1} / 100 }")" --pane "$p"; exit; }
+ done
+ echo "resize-pane: only -L -R -U -D [N] and -Z are supported"; exit 1 ;;
+ select-layout | selectl) flag=-E; if has "$@"; then action spread-layout; else echo "select-layout: only -E is supported"; exit 1; fi ;;
+ display-message | display) if [ $# -gt 0 ]; then say "$*"; else sh "$BIN/display-message"; fi ;;
+ paste-buffer | pasteb) sh "$BIN/paste-buffer" ;;
+ source-file | source) "$H" server reload-config ;;
+ detach-client | detach) echo "detach-client: not scriptable in herdr; use prefix+d"; exit 1 ;;
+ *) "$H" "$cmd" "$@"; echo; exit 2 ;;
+ esac
+}
+
+out="$(run "$@" 2>&1)"
+status=$?
+# tmux's own commands close the prompt quietly on success; errors, and
+# anything handed to the herdr CLI, stay on screen until a key.
+case "$status" in
+0) ;;
+*) printf '%s\n' "$out"; pause ;;
+esac
diff --git a/tmuxrdr/plugin/bin/confirm-kill b/tmuxrdr/plugin/bin/confirm-kill
new file mode 100755
index 00000000..b8826bb8
--- /dev/null
+++ b/tmuxrdr/plugin/bin/confirm-kill
@@ -0,0 +1,20 @@
+#!/bin/sh
+# tmux prefix+x and prefix+& ask before killing -- confirm-before -p
+# "kill-pane #P? (y/n)" and "kill-window #W? (y/n)" -- and so does this, run
+# as a popup from config.toml. y kills; any other key cancels.
+. "$(dirname "$0")/../lib.sh"
+
+p="$(active_pane)"
+case "${1:-}" in
+pane)
+ printf 'kill-pane %s? (y/n)' "$(pane_index "$p")"
+ [ "$(read_key)" = y ] && "$H" pane close "$p" >/dev/null
+ ;;
+window)
+ tab="$(pane_info "$p" tab_id)"
+ name="$("$H" tab get "$tab" | jq -r '.result.tab.label // empty')"
+ printf 'kill-window %s? (y/n)' "$name"
+ [ "$(read_key)" = y ] && "$H" tab close "$tab" >/dev/null
+ ;;
+esac
+exit 0
diff --git a/tmuxrdr/plugin/bin/display-message b/tmuxrdr/plugin/bin/display-message
new file mode 100755
index 00000000..e2ff0192
--- /dev/null
+++ b/tmuxrdr/plugin/bin/display-message
@@ -0,0 +1,15 @@
+#!/bin/sh
+# tmux prefix+i (display-message), in tmux's default template -- measured on
+# tmux 3.7c: "[0] 0:zsh, current pane 0 - (10:30 05-Oct-26)", i.e.
+# [#S] #I:#W, current pane #P - (%H:%M %d-%b-%y). Session is the workspace,
+# window index its place in the tab bar, window name the tab label.
+. "$(dirname "$0")/../lib.sh"
+
+p="$(active_pane)"
+ws="$(pane_info "$p" workspace_id)"
+tab="$(pane_info "$p" tab_id)"
+session="$("$H" workspace get "$ws" | jq -r '.result.workspace.label // empty')"
+line="$(workspace_tabs "$ws" | grep -n -F "$tab " | head -n 1)"
+window_index="${line%%:*}"
+window_name="$(printf '%s' "${line#*:}" | cut -f2)"
+say "[$session] $window_index:$window_name, current pane $(pane_index "$p") - ($(date '+%H:%M %d-%b-%y'))"
diff --git a/tmuxrdr/plugin/bin/last b/tmuxrdr/plugin/bin/last
new file mode 100755
index 00000000..2d3e137d
--- /dev/null
+++ b/tmuxrdr/plugin/bin/last
@@ -0,0 +1,25 @@
+#!/bin/sh
+# tmux prefix+L (switch-client -l): back to the previously focused workspace --
+# herdr's workspace is tmux's session. `last window` is tmux's prefix+l
+# (last-window): this machine's tmux.conf took l for select-pane -R, so it has
+# no key there and none here, but the : prompt has it. Going back focuses the
+# old one, which track-focus records, so repeating toggles -- as in tmux.
+. "$(dirname "$0")/../lib.sh"
+
+dir="$(state_dir)"
+case "${1:-}" in
+window)
+ ws="$(pane_info "$(active_pane)" workspace_id)"
+ t="$(cat "$dir/tab.$(key_of "$ws").previous" 2>/dev/null || true)"
+ [ -n "$t" ] && "$H" tab get "$t" >/dev/null 2>&1 || fail "no last window"
+ "$H" tab focus "$t" >/dev/null
+ ;;
+session)
+ w="$(cat "$dir/workspace.previous" 2>/dev/null || true)"
+ [ -n "$w" ] && "$H" workspace get "$w" >/dev/null 2>&1 || fail "can't find last session"
+ "$H" workspace focus "$w" >/dev/null
+ ;;
+*)
+ fail "usage: last window|session"
+ ;;
+esac
diff --git a/tmuxrdr/plugin/bin/mark b/tmuxrdr/plugin/bin/mark
new file mode 100755
index 00000000..1a3aaaad
--- /dev/null
+++ b/tmuxrdr/plugin/bin/mark
@@ -0,0 +1,23 @@
+#!/bin/sh
+# tmux prefix+m (select-pane -m) toggles the mark on the current pane; prefix+M
+# (select-pane -M) clears it. tmux shows the mark as a highlighted border,
+# which herdr cannot draw, so marking says so in a toast instead. Used by
+# `swap-pane marked` (the : prompt's swap-pane with no target).
+. "$(dirname "$0")/../lib.sh"
+
+f="$(state_dir)/marked"
+case "${1:-toggle}" in
+toggle)
+ p="$(active_pane)"
+ if [ "$(cat "$f" 2>/dev/null || true)" = "$p" ]; then
+ rm -f "$f"
+ say "pane $(pane_index "$p") unmarked"
+ else
+ printf '%s\n' "$p" >"$f"
+ say "pane $(pane_index "$p") marked"
+ fi
+ ;;
+clear)
+ rm -f "$f"
+ ;;
+esac
diff --git a/tmuxrdr/plugin/bin/move-window b/tmuxrdr/plugin/bin/move-window
new file mode 100755
index 00000000..a81567a9
--- /dev/null
+++ b/tmuxrdr/plugin/bin/move-window
@@ -0,0 +1,34 @@
+#!/bin/sh
+# tmux prefix+. (command-prompt -T target { move-window -t "%%" }): move the
+# current window to index N. Herdr's tabs are positions -- tmux with
+# renumber-windows on -- so N is the place in the tab bar it moves to, where
+# tmux would refuse an index in use. Prompts for the target (run as a popup),
+# or takes N as its argument. Over the socket's tab.move, whose insert_index
+# counts slots in the list as it is before the move: moving right is one more.
+. "$(dirname "$0")/../lib.sh"
+
+n="${1:-}"
+if [ -z "$n" ]; then
+ printf '(target) '
+ IFS= read -r n || exit 0
+fi
+n="${n#:}"
+[ -n "$n" ] || exit 0
+case "$n" in
+ *[!0-9]*) fail "can't find window: $n" ;;
+esac
+
+p="$(active_pane)"
+ws="$(pane_info "$p" workspace_id)"
+tab="$(pane_info "$p" tab_id)"
+tabs="$(workspace_tabs "$ws" | cut -f1)"
+count="$(printf '%s\n' "$tabs" | wc -l | tr -d ' ')"
+[ "$n" -ge 1 ] && [ "$n" -le "$count" ] || fail "index $n out of range"
+from="$(printf '%s\n' "$tabs" | grep -n -x -F "$tab" | cut -d: -f1)"
+[ "$n" -eq "$from" ] && exit 0
+if [ "$n" -gt "$from" ]; then
+ at="$n"
+else
+ at="$((n - 1))"
+fi
+api "{\"id\":\"tmux-mode\",\"method\":\"tab.move\",\"params\":{\"tab_id\":\"$tab\",\"insert_index\":$at}}" >/dev/null
diff --git a/tmuxrdr/plugin/bin/paste-buffer b/tmuxrdr/plugin/bin/paste-buffer
new file mode 100755
index 00000000..c5c16844
--- /dev/null
+++ b/tmuxrdr/plugin/bin/paste-buffer
@@ -0,0 +1,20 @@
+#!/bin/sh
+# tmux prefix+] (paste-buffer -p): paste the most recent copy into the current
+# pane. Herdr copies to the system clipboard, so that is the buffer. -p means
+# bracketed paste when the application asked for it, and that is what herdr's
+# send-text already does (src/app/api_helpers.rs: encode_api_text).
+. "$(dirname "$0")/../lib.sh"
+
+if command -v pbpaste >/dev/null 2>&1; then
+ buf="$(pbpaste; printf x)"
+elif command -v wl-paste >/dev/null 2>&1; then
+ buf="$(wl-paste --no-newline; printf x)"
+elif command -v xclip >/dev/null 2>&1; then
+ buf="$(xclip -o -selection clipboard; printf x)"
+else
+ fail "no buffers"
+fi
+# The x keeps trailing newlines that $(...) would strip; tmux pastes verbatim.
+buf="${buf%x}"
+[ -n "$buf" ] || fail "no buffers"
+"$H" pane send-text "$(active_pane)" "$buf" >/dev/null
diff --git a/tmuxrdr/plugin/bin/rotate-window b/tmuxrdr/plugin/bin/rotate-window
new file mode 100755
index 00000000..2457fa3b
--- /dev/null
+++ b/tmuxrdr/plugin/bin/rotate-window
@@ -0,0 +1,34 @@
+#!/bin/sh
+# tmux prefix+C-o (rotate-window, upward) and prefix+M-o (rotate-window -D):
+# every pane moves one place in pane-index order, wrapping. Measured on tmux
+# 3.7c: %0 %1 %2 becomes %1 %2 %0 upward, %2 %0 %1 downward, and focus stays on
+# the *position* -- the pane now there becomes current -- so this refocuses it
+# by id over the socket (pane.focus, which the CLI does not wrap).
+. "$(dirname "$0")/../lib.sh"
+
+p="$(active_pane)"
+panes="$(tab_panes "$p")"
+n="$(printf '%s\n' "$panes" | wc -l | tr -d ' ')"
+[ "$n" -gt 1 ] || exit 0
+pos="$(printf '%s\n' "$panes" | grep -n -x -F "$p" | cut -d: -f1)"
+
+# Swaps move panes, ids and all: swapping the first pane with each of the
+# others in turn rotates upward; the last with each, in reverse, downward.
+case "${1:-up}" in
+up)
+ first="$(printf '%s\n' "$panes" | sed -n 1p)"
+ printf '%s\n' "$panes" | sed 1d | while IFS= read -r t; do
+ swap "$first" "$t"
+ done
+ ;;
+down)
+ last="$(printf '%s\n' "$panes" | sed -n "${n}p")"
+ printf '%s\n' "$panes" | sed '$d' | sed -n '1!G;h;$p' | while IFS= read -r t; do
+ swap "$last" "$t"
+ done
+ ;;
+*)
+ fail "usage: rotate-window up|down"
+ ;;
+esac
+focus_pane "$(tab_panes "$p" | sed -n "${pos}p")"
diff --git a/tmuxrdr/plugin/bin/select-window b/tmuxrdr/plugin/bin/select-window
new file mode 100755
index 00000000..691d7c9f
--- /dev/null
+++ b/tmuxrdr/plugin/bin/select-window
@@ -0,0 +1,22 @@
+#!/bin/sh
+# tmux prefix+0 and prefix+' (select-window -t :=N). Herdr numbers tabs by
+# their place in the tab bar, from 1 -- like tmux with base-index 1 and
+# renumber-windows on -- so N is a position: 0 finds nothing, exactly as
+# prefix+0 does under this machine's base-index 1. With no argument it prompts
+# for the index, as prefix+' does (run as a popup).
+. "$(dirname "$0")/../lib.sh"
+
+n="${1:-}"
+if [ -z "$n" ]; then
+ printf '(index) '
+ IFS= read -r n || exit 0
+fi
+[ -n "$n" ] || exit 0
+case "$n" in
+ *[!0-9]*) fail "can't find window: $n" ;;
+esac
+
+ws="$(pane_info "$(active_pane)" workspace_id)"
+tab="$(workspace_tabs "$ws" | sed -n "${n}p" | cut -f1)"
+[ "$n" -gt 0 ] && [ -n "$tab" ] || fail "can't find window: $n"
+"$H" tab focus "$tab" >/dev/null
diff --git a/tmuxrdr/plugin/bin/show-messages b/tmuxrdr/plugin/bin/show-messages
new file mode 100755
index 00000000..c6d83958
--- /dev/null
+++ b/tmuxrdr/plugin/bin/show-messages
@@ -0,0 +1,8 @@
+#!/bin/sh
+# tmux prefix+~ (show-messages): the server's message log. Herdr's is its
+# server log, beside the session's socket; q leaves, as in tmux's view mode.
+# Run as a popup.
+dir="$(dirname "${HERDR_SOCKET_PATH:?not run by herdr}")"
+log="$dir/herdr-server.log"
+[ -r "$log" ] || log="$dir/herdr.log"
+exec less -R +G "$log"
diff --git a/tmuxrdr/plugin/bin/spread-layout b/tmuxrdr/plugin/bin/spread-layout
new file mode 100755
index 00000000..2b40de2a
--- /dev/null
+++ b/tmuxrdr/plugin/bin/spread-layout
@@ -0,0 +1,25 @@
+#!/bin/sh
+# tmux prefix+E (select-layout -E): spread panes out evenly. tmux evens out the
+# current pane and its neighbours; this evens every split in the tab -- each
+# split's ratio becomes its share of the panes along that direction -- over
+# the socket's layout.set_split_ratio, which keeps every pane running. herdr
+# clamps ratios to 0.1-0.9, so past ten panes in a row they cannot be equal.
+. "$(dirname "$0")/../lib.sh"
+
+tab="$(pane_info "$(active_pane)" tab_id)"
+api "{\"id\":\"tmux-mode\",\"method\":\"layout.export\",\"params\":{\"tab_id\":\"$tab\"}}" \
+ | jq -c --arg tab "$tab" '
+ def cells($d): if .type == "split" and .direction == $d
+ then (.first | cells($d)) + (.second | cells($d)) else 1 end;
+ def splits($p): if .type == "split" then
+ .direction as $d
+ | {path: $p, ratio: ((.first | cells($d)) / ((.first | cells($d)) + (.second | cells($d))))},
+ (.first | splits($p + [false])),
+ (.second | splits($p + [true]))
+ else empty end;
+ .result.layout.root | splits([])
+ | {id: "tmux-mode", method: "layout.set_split_ratio",
+ params: {tab_id: $tab, path: .path, ratio: .ratio}}' \
+ | while IFS= read -r req; do
+ api "$req" >/dev/null
+ done
diff --git a/tmuxrdr/plugin/bin/swap-pane b/tmuxrdr/plugin/bin/swap-pane
new file mode 100755
index 00000000..636c9904
--- /dev/null
+++ b/tmuxrdr/plugin/bin/swap-pane
@@ -0,0 +1,30 @@
+#!/bin/sh
+# tmux prefix+{ / prefix+} (swap-pane -U / -D): swap the current pane with the
+# previous / next one in pane-index order, wrapping; `marked` swaps with the
+# pane prefix+m marked (swap-pane with no target, from the : prompt). The
+# current pane keeps focus as it moves, as in tmux.
+. "$(dirname "$0")/../lib.sh"
+
+p="$(active_pane)"
+case "${1:-}" in
+marked)
+ t="$(cat "$(state_dir)/marked" 2>/dev/null || true)"
+ [ -n "$t" ] && "$H" pane get "$t" >/dev/null 2>&1 || fail "no marked pane"
+ ;;
+prev | next)
+ panes="$(tab_panes "$p")"
+ n="$(printf '%s\n' "$panes" | wc -l | tr -d ' ')"
+ [ "$n" -gt 1 ] || exit 0
+ i="$(printf '%s\n' "$panes" | grep -n -x -F "$p" | cut -d: -f1)"
+ if [ "$1" = prev ]; then
+ j=$(( (i - 2 + n) % n + 1 ))
+ else
+ j=$(( i % n + 1 ))
+ fi
+ t="$(printf '%s\n' "$panes" | sed -n "${j}p")"
+ ;;
+*)
+ fail "usage: swap-pane prev|next|marked"
+ ;;
+esac
+[ "$t" = "$p" ] || swap "$p" "$t"
diff --git a/tmuxrdr/plugin/bin/track-focus b/tmuxrdr/plugin/bin/track-focus
new file mode 100755
index 00000000..4132891f
--- /dev/null
+++ b/tmuxrdr/plugin/bin/track-focus
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Event hook for tab.focused and workspace.focused: keeps the current and
+# previous tab of each workspace, and the current and previous workspace, so
+# `last window` and `last session` know where to go. Hooks run concurrently,
+# so updates take a mkdir lock.
+. "$(dirname "$0")/../lib.sh"
+
+dir="$(state_dir)"
+ev="${HERDR_PLUGIN_EVENT_JSON:-}"
+[ -n "$ev" ] || exit 0
+
+field() {
+ printf '%s' "$ev" | jq -r --arg k "$1" '[.. | objects | .[$k]? | strings][0] // empty'
+}
+
+# record NAME VALUE: VALUE becomes NAME.current, the old one NAME.previous.
+record() {
+ [ -n "$2" ] || return 0
+ cur="$(cat "$dir/$1.current" 2>/dev/null || true)"
+ [ "$cur" = "$2" ] && return 0
+ [ -n "$cur" ] && printf '%s\n' "$cur" >"$dir/$1.previous"
+ printf '%s\n' "$2" >"$dir/$1.current"
+}
+
+tries=0
+until mkdir "$dir/.lock" 2>/dev/null; do
+ tries=$((tries + 1))
+ [ "$tries" -gt 40 ] && break
+ sleep 0.05
+done
+trap 'rmdir "$dir/.lock" 2>/dev/null' EXIT
+
+case "${HERDR_PLUGIN_EVENT:-}" in
+tab.focused) record "tab.$(key_of "$(field workspace_id)")" "$(field tab_id)" ;;
+workspace.focused) record workspace "$(field workspace_id)" ;;
+esac
diff --git a/tmuxrdr/plugin/herdr-plugin.toml b/tmuxrdr/plugin/herdr-plugin.toml
new file mode 100644
index 00000000..a94327fc
--- /dev/null
+++ b/tmuxrdr/plugin/herdr-plugin.toml
@@ -0,0 +1,92 @@
+# tmux mode: the tmux default prefix bindings herdr has no built-in action
+# for, built on the herdr CLI and socket. Part of tmuxrdr; the keys themselves
+# are bound in tmuxrdr/config/config.toml, which also holds the bindings herdr
+# does have. tmuxrdr/scripts/install links this directory as
+# ~/.local/share/tmuxrdr/plugin and registers it with herdr plugin link.
+# What neither config nor plugin can do is in tmuxrdr/ROADMAP.md.
+
+id = "tmuxrdr.tmux-mode"
+name = "tmux mode"
+version = "0.1.0"
+min_herdr_version = "0.9.3"
+description = "tmux default prefix bindings herdr lacks: break-pane, swap and rotate by pane order, marks, last window/session, paste-buffer, display-message, select-layout -E"
+platforms = ["macos", "linux"]
+
+# Focus history for `last window` / `last session`.
+[[events]]
+on = "tab.focused"
+command = ["sh", "bin/track-focus"]
+
+[[events]]
+on = "workspace.focused"
+command = ["sh", "bin/track-focus"]
+
+[[actions]]
+id = "break-pane"
+title = "break-pane (tmux prefix+!)"
+command = ["sh", "bin/break-pane"]
+
+[[actions]]
+id = "select-window-0"
+title = "select-window -t :=0 (tmux prefix+0)"
+command = ["sh", "bin/select-window", "0"]
+
+[[actions]]
+id = "swap-prev"
+title = "swap-pane -U (tmux prefix+{)"
+command = ["sh", "bin/swap-pane", "prev"]
+
+[[actions]]
+id = "swap-next"
+title = "swap-pane -D (tmux prefix+})"
+command = ["sh", "bin/swap-pane", "next"]
+
+[[actions]]
+id = "swap-marked"
+title = "swap-pane with the marked pane"
+command = ["sh", "bin/swap-pane", "marked"]
+
+[[actions]]
+id = "rotate-up"
+title = "rotate-window (tmux prefix+C-o)"
+command = ["sh", "bin/rotate-window", "up"]
+
+[[actions]]
+id = "rotate-down"
+title = "rotate-window -D (tmux prefix+M-o)"
+command = ["sh", "bin/rotate-window", "down"]
+
+[[actions]]
+id = "mark"
+title = "select-pane -m (tmux prefix+m)"
+command = ["sh", "bin/mark", "toggle"]
+
+[[actions]]
+id = "clear-mark"
+title = "select-pane -M (tmux prefix+M)"
+command = ["sh", "bin/mark", "clear"]
+
+[[actions]]
+id = "last-window"
+title = "last-window (tmux prefix+l)"
+command = ["sh", "bin/last", "window"]
+
+[[actions]]
+id = "last-session"
+title = "switch-client -l (tmux prefix+L)"
+command = ["sh", "bin/last", "session"]
+
+[[actions]]
+id = "paste-buffer"
+title = "paste-buffer -p (tmux prefix+])"
+command = ["sh", "bin/paste-buffer"]
+
+[[actions]]
+id = "display-message"
+title = "display-message (tmux prefix+i)"
+command = ["sh", "bin/display-message"]
+
+[[actions]]
+id = "spread-layout"
+title = "select-layout -E (tmux prefix+E)"
+command = ["sh", "bin/spread-layout"]
diff --git a/tmuxrdr/plugin/lib.sh b/tmuxrdr/plugin/lib.sh
new file mode 100644
index 00000000..c7146904
--- /dev/null
+++ b/tmuxrdr/plugin/lib.sh
@@ -0,0 +1,95 @@
+# Shared by the commands in bin/. POSIX sh -- macOS /bin/sh is bash 3.2.
+# Sourced, never run. Every command acts through the herdr CLI, or the raw
+# socket for the one method the CLI does not wrap (pane.focus by id).
+
+set -u
+
+H="${HERDR_BIN_PATH:-herdr}"
+BIN="$(cd "$(dirname "$0")" && pwd)"
+
+# tmux shows messages and errors ("can't find window: 0") on its status line;
+# here they are herdr toasts.
+say() {
+ "$H" notification show "$1" --sound none >/dev/null 2>&1 \
+ || printf '%s\n' "$1" >&2
+}
+
+fail() {
+ say "$1"
+ exit 1
+}
+
+# One newline-delimited JSON request on the session's socket.
+api() {
+ printf '%s\n' "$1" | nc -U -w 2 "${HERDR_SOCKET_PATH:?not run by herdr}"
+}
+
+# The pane the key was pressed in. Keybinding commands and popups get
+# HERDR_ACTIVE_PANE_ID; plugin actions get it in their context JSON; anything
+# else asks the server which pane is focused.
+active_pane() {
+ _p="${HERDR_ACTIVE_PANE_ID:-}"
+ if [ -z "$_p" ] && [ -n "${HERDR_PLUGIN_CONTEXT_JSON:-}" ]; then
+ _p="$(printf '%s' "$HERDR_PLUGIN_CONTEXT_JSON" \
+ | jq -r '.focused_pane_id // empty')"
+ fi
+ if [ -z "$_p" ]; then
+ _p="$("$H" pane current | jq -r '.result.pane.pane_id // empty')"
+ fi
+ [ -n "$_p" ] || fail "no current pane"
+ printf '%s' "$_p"
+}
+
+# pane_info PANE FIELD -- e.g. workspace_id, tab_id.
+pane_info() {
+ "$H" pane get "$1" | jq -r --arg k "$2" '.result.pane[$k] // empty'
+}
+
+# Panes of the tab holding PANE, in layout order: tmux's pane index order.
+tab_panes() {
+ "$H" pane layout --pane "$1" | jq -r '.result.layout.panes[].pane_id'
+}
+
+# 0-based position of PANE in its tab: tmux's #P (pane-base-index 0).
+pane_index() {
+ _i="$(tab_panes "$1" | grep -n -x -F "$1" | cut -d: -f1)"
+ printf '%s' "$((${_i:-1} - 1))"
+}
+
+# Tabs of WORKSPACE in tab-bar order, one "tab_id<TAB>label" per line.
+workspace_tabs() {
+ "$H" tab list --workspace "$1" | jq -r '.result.tabs[] | [.tab_id, .label] | @tsv'
+}
+
+focus_pane() {
+ api "{\"id\":\"tmux-mode\",\"method\":\"pane.focus\",\"params\":{\"pane_id\":\"$1\"}}" >/dev/null
+}
+
+swap() {
+ "$H" pane swap --source-pane "$1" --target-pane "$2" >/dev/null
+}
+
+# One keypress, unbuffered, for (y/n) prompts.
+read_key() {
+ _tty="$(stty -g)"
+ stty -icanon -echo min 1 time 0
+ _k="$(dd bs=1 count=1 2>/dev/null)"
+ stty "$_tty"
+ printf '%s' "$_k"
+}
+
+# Plugin state (marks, focus history) lives where herdr puts this plugin's,
+# one directory per session: herdr shares that directory across sessions,
+# and ids repeat between them -- every named session has its own w1. The
+# session is the directory holding its socket (herdr for the default one).
+state_dir() {
+ _d="${HERDR_PLUGIN_STATE_DIR:?run as a herdr plugin action}"
+ _d="$_d/$(basename "$(dirname "${HERDR_SOCKET_PATH:?not run by herdr}")")"
+ mkdir -p "$_d"
+ printf '%s' "$_d"
+}
+
+# Tab and workspace ids hold colons; keep file names plain.
+key_of() {
+ printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_'
+}