Skip to content

Instantly share code, notes, and snippets.

@stephenfeather
Last active April 19, 2026 13:36
Show Gist options
  • Select an option

  • Save stephenfeather/49fb5f6dfdf379d5e545fef74a8f187a to your computer and use it in GitHub Desktop.

Select an option

Save stephenfeather/49fb5f6dfdf379d5e545fef74a8f187a to your computer and use it in GitHub Desktop.
tmux-msg + tmux-name-pane: send messages to tmux panes by name, with sender attribution and opt-in broadcast modes
#!/usr/bin/env bash
# tmux-msg — send a message to a named tmux window or pane with sender attribution.
#
# A wrapper around `tmux send-keys` that solves four practical problems:
#
# 1. tmux's send-keys needs TWO calls to submit a command: one for the payload,
# one for Enter. Forgetting the second call leaves text sitting un-submitted
# in the target pane. This script always sends both.
#
# 2. send-keys addresses windows only. If a window has multiple panes, only the
# active one receives the keystrokes. This script resolves a friendly label
# to a specific pane via pane titles (`tmux select-pane -T <title>`).
#
# 3. Typos silently drop messages. This script verifies the target exists
# and prints the list of available windows/panes if it doesn't.
#
# 4. Recipients can't tell who sent a message. This script prepends a sender
# tag — `[PANE-TITLE ...]` from inside tmux, `[USER ...]` from outside —
# so the receiving pane's capture-pane history is self-explanatory.
#
# Optional broadcast modes let you fan a single message out to every pane in a
# window, session, or every pane whose title marks it as "interesting."
#
# -----------------------------------------------------------------------------
#
# TARGET SYNTAX (single-target mode)
#
# <label> Window named <label> (any session),
# else pane titled <label> (any session).
# <session>:<label> Window <label> in <session>, else pane titled <label>
# in <session>.
# <session>:<win>:<pt> Pane titled <pt> inside window <win> in <session>.
# <session>:<w>.<p> Raw tmux target (passed through unchanged).
#
# Matching is case-insensitive. Pane titles are matched on a whole-word basis
# with a leading non-word-character strip, so titles like `● build` or
# `⠂ worker-1` (common with tmux statusline plugins that prefix spinners) still
# match `build` / `worker-1`.
#
# USAGE
#
# tmux-msg.sh <target> <message...>
# tmux-msg.sh -s <session> <target> <message...>
#
# NOTE: quote messages that contain shell metacharacters (? * [ ] ! ; & | > <).
# With zsh's default NOMATCH option, a bare `?` triggers a "no matches
# found" error before your script ever runs. Example:
# tmsg architect "is the build stable?" # quoted — works
# tmsg architect is the build stable? # unquoted — zsh errors
#
# tmux-msg.sh --all <session> <message...> Broadcast: every pane in <session>.
# tmux-msg.sh --window <target> <message...> Broadcast: every pane in a window.
# tmux-msg.sh --roles [<session>] <message...> Broadcast: every pane whose title
# differs from the hostname and from
# the pane's current command.
#
# tmux-msg.sh --list List windows.
# tmux-msg.sh --list-panes List panes with titles.
# tmux-msg.sh --resolve <target> Dry-run resolution.
# tmux-msg.sh -h | --help Show help.
#
# SENDER ATTRIBUTION (prepended automatically)
#
# Inside tmux → "[AGENT <source>]"
# <source> is chosen in this order:
# 1. $TMUX_AGENT_NAME (explicit override)
# 2. Current pane title (if non-default: not hostname, not user@host)
# 3. Current window name (#W)
#
# Outside tmux → "[HUMAN <name>]"
# <name> defaults to $TMUX_MSG_SENDER, else $USER.
#
# Both tags are labels only — rename them in your shell if you prefer:
# export TMUX_MSG_TAG_INSIDE=PANE
# export TMUX_MSG_TAG_OUTSIDE=USER
#
# SETTING PANE TITLES
#
# From inside the pane: tmux select-pane -T <label>
# From another pane: tmux select-pane -t <session>:<w>.<p> -T <label>
#
# A companion helper `tmux-name-pane.sh` wraps this and can also persist the
# title so shell prompt updates don't clobber it.
#
# EXIT CODES
#
# 0 success (message sent, or listing produced)
# 1 usage error
# 2 target not found
# 127 tmux not installed
#
# LICENSE: MIT. No warranty. Script does what it says; review before use.
set -euo pipefail
usage() {
sed -n '2,72p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
SESSION=""
LIST=0
LIST_PANES=0
DRY_RUN=0
MODE="single"
MODE_ARG=""
TAG_INSIDE="${TMUX_MSG_TAG_INSIDE:-AGENT}"
TAG_OUTSIDE="${TMUX_MSG_TAG_OUTSIDE:-HUMAN}"
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage 0 ;;
--list|-l) LIST=1; shift ;;
--list-panes|-L) LIST_PANES=1; shift ;;
-s|--session) SESSION="$2"; shift 2 ;;
-n|--dry-run|--resolve) DRY_RUN=1; shift ;;
--all) MODE="all"; MODE_ARG="$2"; shift 2 ;;
--window|--broadcast) MODE="window"; MODE_ARG="$2"; shift 2 ;;
--roles)
MODE="roles"; shift
if [ $# -gt 0 ] && [[ "$1" != *" "* ]] && tmux has-session -t "$1" 2>/dev/null; then
MODE_ARG="$1"; shift
fi
;;
--) shift; break ;;
-*) echo "unknown flag: $1" >&2; usage 1 ;;
*) break ;;
esac
done
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not installed" >&2
exit 127
fi
# ----- Listing helpers --------------------------------------------------------
list_windows() {
local scope=()
if [ -n "$SESSION" ]; then scope=(-t "$SESSION"); else scope=(-a); fi
tmux list-windows "${scope[@]}" -F '#{session_name}:#{window_index} #{window_name}'
}
list_panes() {
local scope=()
if [ -n "$SESSION" ]; then scope=(-s -t "$SESSION"); else scope=(-a); fi
tmux list-panes "${scope[@]}" \
-F '#{session_name}:#{window_index}.#{pane_index} #{window_name} #{pane_title} #{pane_current_command}'
}
if [ "$LIST" -eq 1 ]; then
list_windows
exit 0
fi
if [ "$LIST_PANES" -eq 1 ]; then
printf '%-30s %-18s %-22s %s\n' 'TARGET' 'WINDOW' 'PANE_TITLE' 'COMMAND'
list_panes | awk -F'\t' '{printf "%-30s %-18s %-22s %s\n", $1, $2, $3, $4}'
exit 0
fi
# ----- Pane-title matcher (shared awk) ----------------------------------------
#
# Matches <title> against <label>:
# 1. exact case-insensitive equality
# 2. whole-word case-insensitive substring (so "build" matches "● build"
# but "api" does NOT match "apidev")
# 3. after stripping leading non-word characters (spinners, emoji, punctuation),
# exact case-insensitive equality
#
# Uses tolower() rather than IGNORECASE so it works with BSD awk (macOS default).
MATCH_AWK='
function match_label(title, lbl, norm, t, l) {
t = tolower(title); l = tolower(lbl)
if (t == l) return 1
if (t ~ "(^|[^[:alnum:]_])" l "([^[:alnum:]_]|$)") return 1
norm = t
sub(/^[^[:alnum:]_]+/, "", norm)
if (norm == l) return 1
return 0
}
'
# ----- Resolvers --------------------------------------------------------------
# Resolve a friendly target string to a tmux-native "session:window.pane" form.
# Writes the resolved target on stdout. Returns 1 on failure (empty stdout).
resolve_target() {
local target="$1"
local session="" window="" pane_label=""
# If --session is set and target has no session part, prepend it.
if [ -n "$SESSION" ] && [[ "$target" != *:* ]]; then
target="${SESSION}:${target}"
fi
# Pass-through: "<...>.<pane>" is already a tmux-native target.
if [[ "$target" == *.* ]]; then
echo "$target"
return 0
fi
local IFS=':'
read -r -a parts <<< "$target"
case "${#parts[@]}" in
1) window="${parts[0]}" ;;
2) session="${parts[0]}"; window="${parts[1]}" ;;
3) session="${parts[0]}"; window="${parts[1]}"; pane_label="${parts[2]}" ;;
*) return 1 ;;
esac
unset IFS
# Three-part form: explicit pane label inside a window.
if [ -n "$pane_label" ]; then
local match
match=$(tmux list-panes -t "${session}:${window}" \
-F '#{session_name}:#{window_index}.#{pane_index} #{pane_title} #{window_name}' 2>/dev/null \
| awk -F'\t' -v lbl="$pane_label" "$MATCH_AWK"'
{ if (match_label($2, lbl) || match_label($3, lbl)) { print $1; exit } }')
[ -n "$match" ] && echo "$match" && return 0
return 1
fi
# Try window-name first (exact, case-insensitive).
local win_match
win_match=$(tmux list-windows $( [ -n "$session" ] && printf -- '-t %s' "$session" || printf -- '-a' ) \
-F '#{session_name}:#{window_name}' 2>/dev/null \
| awk -F: -v sess="$session" -v w="$window" '
(sess == "" || tolower($1) == tolower(sess)) && tolower($2) == tolower(w) { print $1":"$2; exit }')
if [ -n "$win_match" ]; then echo "$win_match"; return 0; fi
# Fall back to pane-title search (loose match via MATCH_AWK).
local pane_match
pane_match=$(tmux list-panes -a \
-F '#{session_name}:#{window_index}.#{pane_index} #{session_name} #{pane_title}' 2>/dev/null \
| awk -F'\t' -v sess="$session" -v lbl="$window" "$MATCH_AWK"'
(sess == "" || tolower($2) == tolower(sess)) { if (match_label($3, lbl)) { print $1; exit } }')
[ -n "$pane_match" ] && echo "$pane_match" && return 0
return 1
}
# Resolve <target> to a "session:window" (strip .pane or look up by name).
resolve_window() {
local target="$1"
if [ -n "$SESSION" ] && [[ "$target" != *:* ]]; then
target="${SESSION}:${target}"
fi
if [[ "$target" == *.* ]]; then
echo "${target%.*}"
return 0
fi
local session="" window="$target"
if [[ "$target" == *:* ]]; then
session="${target%%:*}"; window="${target#*:}"
fi
tmux list-windows $( [ -n "$session" ] && printf -- '-t %s' "$session" || printf -- '-a' ) \
-F '#{session_name}:#{window_name}' 2>/dev/null \
| awk -F: -v sess="$session" -v w="$window" '
(sess == "" || tolower($1) == tolower(sess)) && tolower($2) == tolower(w) { print $1":"$2; exit }'
}
# List panes whose titles look like meaningful labels (skip bare shells).
# Heuristic "role pane" = title is set, not equal to hostname, not user@host form,
# not a hostname.local/.lan, and not equal to the pane's current command.
# Callers can extend this by filtering stdout further.
list_role_panes() {
local scope=()
if [ -n "${1:-}" ]; then scope=(-s -t "$1"); else scope=(-a); fi
local host
host="$(tmux display-message -p '#h' 2>/dev/null || hostname -s 2>/dev/null || echo '')"
tmux list-panes "${scope[@]}" \
-F '#{session_name}:#{window_index}.#{pane_index} #{pane_title} #{pane_current_command}' 2>/dev/null \
| awk -F'\t' -v host="$host" '
{
title = $2; cmd = $3
if (title == "") next
if (title == host) next
if (title ~ "@") next
if (title ~ /\.(local|lan)$/) next
if (title == cmd) next
print $1 "\t" title
}'
}
# ----- Argument parse for target + message ------------------------------------
TARGET=""
MSG=""
if [ "$DRY_RUN" -eq 1 ]; then
if [ $# -lt 1 ]; then echo "usage: tmux-msg.sh --resolve <target>" >&2; exit 1; fi
TARGET="$1"
MSG="(dry run)"
else
case "$MODE" in
single)
if [ $# -lt 2 ]; then usage 1; fi
TARGET="$1"; shift
MSG="$*"
;;
all|window|roles)
if [ $# -lt 1 ]; then
echo "${MODE} mode needs a message" >&2; usage 1
fi
MSG="$*"
;;
esac
fi
# ----- Sender attribution -----------------------------------------------------
if [ -n "${TMUX:-}" ]; then
SRC_TITLE="$(tmux display-message -p '#{pane_title}' 2>/dev/null || true)"
SRC_WIN="$(tmux display-message -p '#W' 2>/dev/null || echo unknown)"
SRC_HOST="$(tmux display-message -p '#h' 2>/dev/null || true)"
if [ -n "${TMUX_AGENT_NAME:-}" ]; then
SRC="$TMUX_AGENT_NAME"
elif [ -n "$SRC_TITLE" ] && [ "$SRC_TITLE" != "$SRC_HOST" ] && [[ "$SRC_TITLE" != *@* ]]; then
SRC="$SRC_TITLE"
else
SRC="$SRC_WIN"
fi
SENDER="[${TAG_INSIDE} ${SRC}]"
else
SENDER="[${TAG_OUTSIDE} ${TMUX_MSG_SENDER:-${USER:-unknown}}]"
fi
PAYLOAD="${SENDER} ${MSG}"
# ----- Send helper ------------------------------------------------------------
send_to() {
local tgt="$1"
tmux send-keys -t "$tgt" -- "$PAYLOAD"
tmux send-keys -t "$tgt" Enter
echo "→ ${tgt}: ${PAYLOAD}"
}
# ----- Dispatch ---------------------------------------------------------------
if [ "$DRY_RUN" -eq 1 ]; then
FULL_TARGET="$(resolve_target "$TARGET" || true)"
if [ -z "$FULL_TARGET" ]; then
echo "tmux-msg: could not resolve target '$TARGET'" >&2
exit 2
fi
echo "resolved: ${TARGET} → ${FULL_TARGET}"
echo "sender: ${SENDER}"
exit 0
fi
case "$MODE" in
single)
FULL_TARGET="$(resolve_target "$TARGET" || true)"
if [ -z "$FULL_TARGET" ]; then
echo "tmux-msg: could not resolve target '$TARGET'" >&2
echo "" >&2
echo "windows:" >&2
list_windows | sed 's/^/ /' >&2
echo "" >&2
echo "panes (session:w.p window pane_title command):" >&2
list_panes | awk -F'\t' '{printf " %-28s %-16s %-20s %s\n", $1, $2, $3, $4}' >&2
echo "" >&2
echo "tip: set a pane title with tmux select-pane -T <label>" >&2
exit 2
fi
send_to "$FULL_TARGET"
;;
all)
if ! tmux has-session -t "$MODE_ARG" 2>/dev/null; then
echo "tmux-msg: no session named '$MODE_ARG'" >&2
exit 2
fi
targets=$(tmux list-panes -s -t "$MODE_ARG" \
-F '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null)
if [ -z "$targets" ]; then
echo "tmux-msg: session '$MODE_ARG' has no panes" >&2
exit 2
fi
count=0
while IFS= read -r t; do send_to "$t"; count=$((count+1)); done <<< "$targets"
echo "broadcast: ${count} panes in session '${MODE_ARG}'"
;;
window)
win="$(resolve_window "$MODE_ARG" || true)"
if [ -z "$win" ]; then
echo "tmux-msg: could not resolve window '$MODE_ARG'" >&2
exit 2
fi
targets=$(tmux list-panes -t "$win" \
-F '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null)
if [ -z "$targets" ]; then
echo "tmux-msg: window '$win' has no panes" >&2
exit 2
fi
count=0
while IFS= read -r t; do send_to "$t"; count=$((count+1)); done <<< "$targets"
echo "broadcast: ${count} panes in window '${win}'"
;;
roles)
agents=$(list_role_panes "$MODE_ARG" 2>/dev/null || true)
if [ -z "$agents" ]; then
echo "tmux-msg: no role-titled panes found${MODE_ARG:+ in session '$MODE_ARG'}" >&2
echo "(role panes are ones whose title differs from the hostname and current command)" >&2
exit 2
fi
count=0
while IFS=$'\t' read -r t title; do
[ -z "$t" ] && continue
send_to "$t"
count=$((count+1))
done <<< "$agents"
echo "broadcast: ${count} role panes${MODE_ARG:+ in session '$MODE_ARG'}"
;;
esac
#!/usr/bin/env bash
# tmux-name-pane — set a tmux pane's title so it can be addressed by name.
#
# tmux panes have a `pane_title` attribute that tools can query (`#T` in format
# strings) and that can be displayed in the status line. Setting a meaningful
# title lets other tools — notably the companion `tmux-msg.sh` script — route
# messages to a pane by role rather than by numeric index.
#
# This helper does three things:
#
# 1. Runs `tmux select-pane -T <title>` on the requested pane.
# 2. Optionally persists the title by setting the `allow-rename` and
# `automatic-rename` options off for that pane, so a shell prompt or
# program doesn't overwrite the title via terminal escape sequences.
# 3. Optionally emits a shell-escape sequence you can `echo` inside the pane
# to set the title from within the pane's own shell.
#
# -----------------------------------------------------------------------------
#
# USAGE
#
# tmux-name-pane.sh <title> Set title of current pane.
# tmux-name-pane.sh -t <target> <title> Set title of a specific pane
# (<target> is any tmux pane
# target, e.g. "session:0.1").
# tmux-name-pane.sh --persist <title> Also disable auto-rename so
# the title sticks.
# tmux-name-pane.sh --escape <title> Print the ANSI escape that
# sets the title from within
# the pane's own shell (for
# use in a PS1/precmd hook).
# tmux-name-pane.sh --clear Clear the current pane's title.
# tmux-name-pane.sh --list List all panes with titles.
# tmux-name-pane.sh -h | --help Show help.
#
# EXAMPLES
#
# # Inside the pane you want to name:
# tmux-name-pane.sh build
#
# # From elsewhere, naming a specific pane:
# tmux-name-pane.sh -t work:0.2 worker-1
#
# # Make it stick across shell prompts that would otherwise retitle the pane:
# tmux-name-pane.sh --persist api-server
#
# # Emit an escape you can put in zsh's precmd to keep the name set:
# # precmd() { printf '\033]2;%s\033\\' api-server }
# eval "$(tmux-name-pane.sh --escape api-server)"
#
# PERSISTENCE NOTES
#
# tmux sets a pane's title from two sources:
# a) `tmux select-pane -T <title>` (what this script uses)
# b) OSC 2 / OSC 0 escape sequences printed inside the pane (what most
# shells do via their prompt)
#
# If your shell's prompt emits OSC 2, it will override (a) on every prompt.
# `--persist` turns off tmux's `allow-rename` option for the target pane,
# which tells tmux to ignore those escape sequences. This is the correct
# fix for most cases; if you also want your own shell-driven title, use
# `--escape` to generate the sequence instead of relying on the default
# prompt.
#
# EXIT CODES
#
# 0 success
# 1 usage error
# 2 tmux operation failed
# 127 tmux not installed
#
# LICENSE: MIT. No warranty. Review before use.
set -euo pipefail
usage() {
sed -n '2,57p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
TARGET=""
PERSIST=0
ESCAPE_ONLY=0
CLEAR=0
LIST=0
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage 0 ;;
-t|--target) TARGET="$2"; shift 2 ;;
--persist) PERSIST=1; shift ;;
--escape) ESCAPE_ONLY=1; shift ;;
--clear) CLEAR=1; shift ;;
--list|-l) LIST=1; shift ;;
--) shift; break ;;
-*) echo "unknown flag: $1" >&2; usage 1 ;;
*) break ;;
esac
done
if ! command -v tmux >/dev/null 2>&1; then
echo "tmux not installed" >&2
exit 127
fi
if [ "$LIST" -eq 1 ]; then
tmux list-panes -a \
-F '#{session_name}:#{window_index}.#{pane_index} #{window_name} #{pane_title}' \
| awk -F'\t' 'BEGIN { printf "%-28s %-18s %s\n", "TARGET", "WINDOW", "PANE_TITLE" }
{ printf "%-28s %-18s %s\n", $1, $2, $3 }'
exit 0
fi
if [ "$CLEAR" -eq 1 ]; then
if [ -n "$TARGET" ]; then
tmux select-pane -t "$TARGET" -T ""
else
[ -n "${TMUX:-}" ] || { echo "--clear needs -t <target> when run outside tmux" >&2; exit 1; }
tmux select-pane -T ""
fi
exit 0
fi
if [ "$ESCAPE_ONLY" -eq 1 ]; then
if [ $# -lt 1 ]; then echo "usage: tmux-name-pane.sh --escape <title>" >&2; exit 1; fi
TITLE="$1"
# OSC 2 sets the window title; tmux reads it as the pane title when allow-rename is on.
# Emit a printf command the caller can eval or drop into precmd.
printf "printf '\\\\033]2;%%s\\\\033\\\\\\\\' %q\n" "$TITLE"
exit 0
fi
if [ $# -lt 1 ]; then
usage 1
fi
TITLE="$1"
if [ -n "$TARGET" ]; then
tmux select-pane -t "$TARGET" -T "$TITLE"
SCOPE=(-t "$TARGET")
else
if [ -z "${TMUX:-}" ]; then
echo "no --target specified and not running inside tmux" >&2
exit 1
fi
tmux select-pane -T "$TITLE"
SCOPE=()
fi
if [ "$PERSIST" -eq 1 ]; then
# allow-rename: if set (default), OSC escapes from the pane re-title it.
# Turning it off pins the title we just set.
tmux set-option -p "${SCOPE[@]}" allow-rename off 2>/dev/null || true
tmux set-window-option "${SCOPE[@]}" automatic-rename off 2>/dev/null || true
fi
echo "set pane title${TARGET:+ on $TARGET}: $TITLE"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment