Last active
April 13, 2026 21:59
-
-
Save dmitriid/523a5229c9a17a1cf4ad9182f4c58cf5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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