Skip to content

Instantly share code, notes, and snippets.

@dmitriid
Last active April 13, 2026 21:59
Show Gist options
  • Select an option

  • Save dmitriid/523a5229c9a17a1cf4ad9182f4c58cf5 to your computer and use it in GitHub Desktop.

Select an option

Save dmitriid/523a5229c9a17a1cf4ad9182f4c58cf5 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
#
# tammy — a friendly wrapper around tmux.
#
# tmux is powerful but its CLI is full of cryptic flags (-h means horizontal
# but splits vertically, -s means source in one command and session in another,
# etc.). tammy replaces all of that with human-readable subcommands like
# "tammy split right", "tammy connect", and "tammy resize down 5".
#
# It also handles the annoying context-dependent differences (e.g. attach vs
# switch-client depending on whether you're already inside tmux) so you don't
# have to think about it. If fzf is available, session selection is interactive.
#
# ── Dependencies ──────────────────────────────────────────────────────
#
# Session save/restore uses tmux-resurrect, managed via TPM (Tmux Plugin
# Manager). TPM and its plugins live in a sibling "tools/tpm/" directory
# relative to this script (i.e. SCRIPT_DIR/../tools/tpm/).
#
# Setup:
#
# 1. Clone TPM next to this script:
# git clone https://github.com/tmux-plugins/tpm <tools-dir>/tpm
#
# 2. Add to ~/.tmux.conf:
# set-environment -g TMUX_PLUGIN_MANAGER_PATH "<tools-dir>/tpm/plugins"
# set -g @plugin 'tmux-plugins/tpm'
# set -g @plugin 'tmux-plugins/tmux-resurrect'
# run "<tools-dir>/tpm/tpm"
#
# 3. Install plugins:
# <tools-dir>/tpm/bin/install_plugins
#
# Plugin management (all via <tools-dir>/tpm/bin/):
# install_plugins install new @plugin entries
# update_plugins all update all installed plugins
# clean_plugins remove plugins no longer in .tmux.conf
#
# Or use tmux keybindings:
# prefix + I install plugins
# prefix + U update plugins
# prefix + Ctrl-s save sessions (tmux-resurrect)
# prefix + Ctrl-r restore sessions (tmux-resurrect)
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_NAME="tammy"
usage() {
cat <<EOF
Usage: $SCRIPT_NAME <command> [args]
Commands:
close Close current pane
connect [name] Attach to an existing session (interactive if no name)
detach Detach from current session
duplicate --to <name> [--from <name>] [--no-switch] Duplicate a session layout with fresh shells
help Show this help
kill [name] Kill a session (interactive if no name)
list, ls List all tmux sessions
move <up|down|left|right> Move current pane to that edge
new <name> Create a new tmux session
popup <command> Run command in a tmux popup
reload Reload tmux config
rename <old> <new> Rename a session
resize <up|top|down|bottom|left|right> <n> Resize current pane by n cells
restore Restore all saved tmux sessions (via tmux-resurrect)
save Save all tmux sessions (via tmux-resurrect)
set-dir <dir> Set cwd in all panes of the current tmux session
split <right|left|bottom|down|up|top> Split current pane
swap <up|down|left|right> Swap current pane with neighbor
zoom Toggle current pane fullscreen
EOF
}
_pick_session() {
local sessions
sessions=$(tmux list-sessions -F "#{session_name}" 2>/dev/null) || {
echo "No tmux sessions running." >&2; return 1
}
if command -v fzf &>/dev/null; then
echo "$sessions" | fzf --prompt="Session: "
else
local count
count=$(echo "$sessions" | wc -l)
if [ "$count" -eq 1 ]; then
echo "$sessions"
return
fi
echo "Available sessions:" >&2
local i=1
while IFS= read -r s; do
echo " $i) $s" >&2
i=$((i + 1))
done <<< "$sessions"
printf "Select session [1-%d]: " "$count" >&2
read -r choice
local picked
picked=$(echo "$sessions" | sed -n "${choice}p")
if [ -z "$picked" ]; then
echo "Invalid selection." >&2; return 1
fi
echo "$picked"
fi
}
_current_session() {
[ -n "${TMUX:-}" ] || return 1
tmux display-message -p '#S'
}
_session_exists() {
local name="$1"
tmux has-session -t "$name" 2>/dev/null
}
_list_session_panes() {
local session="$1"
tmux list-panes -s -t "$session" -F '#{pane_id}'
}
_list_windows() {
local session="$1"
tmux list-windows -t "$session" -F '#{window_index}'
}
_list_panes() {
local target="$1"
tmux list-panes -t "$target" -F '#{pane_current_path}'
}
_window_name() {
local target="$1"
tmux display-message -p -t "$target" '#{window_name}'
}
_window_layout() {
local target="$1"
tmux display-message -p -t "$target" '#{window_layout}'
}
_rebuild_window_from_source() {
local source_window="$1"
local dest_window="$2"
local layout
local -a pane_paths=()
local i
layout=$(_window_layout "$source_window")
mapfile -t pane_paths < <(_list_panes "$source_window")
for ((i = 1; i < ${#pane_paths[@]}; i++)); do
tmux split-window -d -v -t "${dest_window}.0" -c "${pane_paths[$i]}"
done
tmux select-layout -t "$dest_window" "$layout" >/dev/null
}
cmd_new() {
local name="${1:?Usage: $SCRIPT_NAME new <name>}"
if [ -n "${TMUX:-}" ]; then
tmux new-session -d -s "$name"
tmux switch-client -t "$name"
else
tmux new-session -s "$name"
fi
}
cmd_connect() {
local name="${1:-}"
if [ -z "$name" ]; then
name=$(_pick_session) || exit 1
fi
if [ -n "${TMUX:-}" ]; then
tmux switch-client -t "$name"
else
tmux attach-session -t "$name"
fi
}
cmd_detach() {
tmux detach-client
}
cmd_list() {
tmux list-sessions 2>/dev/null || echo "No tmux sessions running."
}
cmd_kill() {
local name="${1:-}"
if [ -z "$name" ]; then
name=$(_pick_session) || exit 1
fi
tmux kill-session -t "$name"
echo "Killed session: $name"
}
cmd_rename() {
local old="${1:?Usage: $SCRIPT_NAME rename <old> <new>}"
local new="${2:?Usage: $SCRIPT_NAME rename <old> <new>}"
tmux rename-session -t "$old" "$new"
echo "Renamed session: $old -> $new"
}
cmd_duplicate() {
local from=""
local to=""
local no_switch=0
local from_provided=0
local -a window_indices=()
local window_index
local source_window
local dest_window
local window_name
local first_path
while [ "$#" -gt 0 ]; do
case "$1" in
--from)
[ "$#" -ge 2 ] || { echo "Usage: $SCRIPT_NAME duplicate --to <name> [--from <name>] [--no-switch]" >&2; exit 1; }
from="$2"
from_provided=1
shift 2
;;
--to)
[ "$#" -ge 2 ] || { echo "Usage: $SCRIPT_NAME duplicate --to <name> [--from <name>] [--no-switch]" >&2; exit 1; }
to="$2"
shift 2
;;
--no-switch)
no_switch=1
shift
;;
-h|--help|help)
usage
return 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if [ -z "$to" ]; then
echo "Usage: $SCRIPT_NAME duplicate --to <name> [--from <name>] [--no-switch]" >&2
exit 1
fi
if [ "$from_provided" -eq 0 ]; then
from=$(_current_session) || {
echo "No current tmux session. Use --from <name>." >&2
exit 1
}
fi
if ! _session_exists "$from"; then
echo "No such session: $from" >&2
exit 1
fi
if [ "$from" = "$to" ]; then
echo "Source and destination must be different: $from" >&2
exit 1
fi
if _session_exists "$to"; then
tmux kill-session -t "$to"
fi
mapfile -t window_indices < <(_list_windows "$from")
[ "${#window_indices[@]}" -gt 0 ] || {
echo "No windows found in session: $from" >&2
exit 1
}
window_index="${window_indices[0]}"
source_window="${from}:${window_index}"
window_name=$(_window_name "$source_window")
first_path=$(tmux display-message -p -t "${source_window}.0" '#{pane_current_path}')
tmux new-session -d -s "$to" -c "$first_path"
tmux rename-window -t "${to}:0" "$window_name"
_rebuild_window_from_source "$source_window" "${to}:0"
for window_index in "${window_indices[@]:1}"; do
source_window="${from}:${window_index}"
window_name=$(_window_name "$source_window")
first_path=$(tmux display-message -p -t "${source_window}.0" '#{pane_current_path}')
dest_window="${to}:$(tmux new-window -d -P -F '#{window_index}' -t "${to}:" -n "$window_name" -c "$first_path")"
_rebuild_window_from_source "$source_window" "$dest_window"
done
if [ "$no_switch" -eq 1 ]; then
echo "Duplicated session: $from -> $to"
return 0
fi
if [ -n "${TMUX:-}" ]; then
tmux switch-client -t "$to"
else
tmux attach-session -t "$to"
fi
}
cmd_set_dir() {
local dir="${1:-}"
local session=""
local resolved_dir=""
local quoted_dir=""
local pane_id
if [ "$#" -ne 1 ]; then
echo "Usage: $SCRIPT_NAME set-dir <dir>" >&2
exit 1
fi
if [ -z "${TMUX:-}" ]; then
echo "set-dir must be run inside tmux." >&2
exit 1
fi
session=$(_current_session) || {
echo "Could not determine the current tmux session." >&2
exit 1
}
[ -d "$dir" ] || {
echo "No such directory: $dir" >&2
exit 1
}
resolved_dir=$(cd "$dir" && pwd)
printf -v quoted_dir '%q' "$resolved_dir"
while IFS= read -r pane_id; do
tmux send-keys -t "$pane_id" "cd -- $quoted_dir" C-m
done < <(_list_session_panes "$session")
echo "Set directory in session $session: $resolved_dir"
}
cmd_popup() {
local cmd="${1:?Usage: $SCRIPT_NAME popup <command>}"
shift
tmux popup -E "$cmd" "$@"
}
cmd_split() {
local dir="${1:?Usage: $SCRIPT_NAME split <right|left|bottom|up>}"
case "$dir" in
right) tmux split-window -h ;;
left) tmux split-window -hb ;;
bottom|down) tmux split-window -v ;;
up|top) tmux split-window -vb ;;
*) echo "Unknown direction: $dir (use right|left|bottom|down|up|top)" >&2; exit 1 ;;
esac
}
cmd_swap() {
local dir="${1:?Usage: $SCRIPT_NAME swap <up|down|left|right>}"
case "$dir" in
up|top) tmux swap-pane -U ;;
down|bottom) tmux swap-pane -D ;;
left) tmux swap-pane -s '{left-of}' ;;
right) tmux swap-pane -s '{right-of}' ;;
*) echo "Unknown direction: $dir (use up|down|left|right)" >&2; exit 1 ;;
esac
}
cmd_move() {
local dir="${1:?Usage: $SCRIPT_NAME move <up|down|left|right>}"
local pane_id
pane_id=$(tmux display-message -p '#{pane_id}')
case "$dir" in
up|top) tmux move-pane -vb -s "$pane_id" ;;
down|bottom) tmux move-pane -v -s "$pane_id" ;;
left) tmux move-pane -hb -s "$pane_id" ;;
right) tmux move-pane -h -s "$pane_id" ;;
*) echo "Unknown direction: $dir (use up|down|left|right)" >&2; exit 1 ;;
esac
}
cmd_zoom() {
tmux resize-pane -Z
}
cmd_close() {
local pane_count
pane_count=$(tmux list-panes | wc -l)
if [ "$pane_count" -le 1 ]; then
echo "Only one pane in this window. Use 'tmux kill-window' to close the window." >&2
exit 1
fi
tmux kill-pane
}
cmd_resize() {
local dir="${1:?Usage: $SCRIPT_NAME resize <up|down|left|right> <n>}"
local n="${2:?Usage: $SCRIPT_NAME resize <up|down|left|right> <n>}"
case "$dir" in
up|top) tmux resize-pane -U "$n" ;;
down|bottom) tmux resize-pane -D "$n" ;;
left) tmux resize-pane -L "$n" ;;
right) tmux resize-pane -R "$n" ;;
*) echo "Unknown direction: $dir (use up|top|down|bottom|left|right)" >&2; exit 1 ;;
esac
}
cmd_reload() {
tmux source-file ~/.tmux.conf
echo "Reloaded ~/.tmux.conf"
}
TPM_DIR="${TPM_DIR:-${SCRIPT_DIR}/tools/tpm}"
RESURRECT_SAVE_SCRIPT="${TPM_DIR}/plugins/tmux-resurrect/scripts/save.sh"
RESURRECT_RESTORE_SCRIPT="${TPM_DIR}/plugins/tmux-resurrect/scripts/restore.sh"
_require_resurrect() {
if [ ! -x "$RESURRECT_SAVE_SCRIPT" ]; then
echo "tmux-resurrect not found. Run: ${TPM_DIR}/bin/install_plugins" >&2
exit 1
fi
}
_require_tmux() {
if [ -z "${TMUX:-}" ]; then
echo "Must be run inside tmux." >&2
exit 1
fi
}
cmd_save() {
_require_resurrect
_require_tmux
"$RESURRECT_SAVE_SCRIPT" quiet
echo "Session(s) saved via tmux-resurrect."
}
cmd_restore() {
_require_resurrect
_require_tmux
"$RESURRECT_RESTORE_SCRIPT"
echo "Session(s) restored via tmux-resurrect."
}
command="${1:-help}"
shift || true
case "$command" in
new) cmd_new "$@" ;;
connect) cmd_connect "$@" ;;
detach) cmd_detach ;;
kill) cmd_kill "$@" ;;
rename) cmd_rename "$@" ;;
duplicate) cmd_duplicate "$@" ;;
save) cmd_save "$@" ;;
restore) cmd_restore "$@" ;;
set-dir) cmd_set_dir "$@" ;;
list|ls) cmd_list ;;
popup) cmd_popup "$@" ;;
split) cmd_split "$@" ;;
swap) cmd_swap "$@" ;;
move) cmd_move "$@" ;;
zoom) cmd_zoom ;;
close) cmd_close ;;
resize) cmd_resize "$@" ;;
reload) cmd_reload ;;
help|--help|-h) usage ;;
*) echo "Unknown command: $command" >&2; usage >&2; exit 1 ;;
esac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment