#!/bin/bash # tmux-util — small utilities for managing tmux sessions. # # Subcommands: # go attach to if it exists, otherwise create it # pick fzf-driven session switcher # ls opinionated session listing (attached, idle, windows, cwd) # find locate panes whose foreground process matches # reap gracefully exit every unattached session (skipping aiv-*) # rename fzf-pick a session, prompt for a new name, rename it # # Run with no arguments to print this help. set -uo pipefail # ----------------------------------------------------------------------------- # Usage # ----------------------------------------------------------------------------- usage() { cat <<'EOF' Usage: tmux-util [args] Subcommands: go Attach to session ; create it (in $PWD) if missing. pick Fzf-driven session switcher. ls List sessions with attached / idle / window / cwd columns. find Locate panes whose foreground process matches . reap Send SIGHUP to every unattached session's panes and close the session. Sessions matching $TMUX_UTIL_REAP_SKIP (default: ^aiv-) are skipped. rename Pick a session via fzf, prompt for a new name, rename it. Run with no arguments to print this help. EOF } # ----------------------------------------------------------------------------- # rename # ----------------------------------------------------------------------------- cmd_rename() { local sessions sessions=$(tmux list-sessions \ -F '#{session_attached} #{session_name}' 2>/dev/null || true) if [ -z "$sessions" ]; then echo "No tmux sessions to rename." return 0 fi local choice choice=$(echo "$sessions" | awk '{ state = ($1 == "1") ? "attached" : "detached" printf "%-9s %s\n", state, $2 }' | fzf --header="Pick a session to rename" | awk '{print $2}') if [ -z "$choice" ]; then # User cancelled. return 0 fi local new_name printf 'New name for "%s": ' "$choice" read -r new_name if [ -z "$new_name" ]; then echo "tmux-util rename: empty new name, cancelled." >&2 return 1 fi if [ "$new_name" = "$choice" ]; then echo "tmux-util rename: new name same as old, no change." return 0 fi if tmux has-session -t "=$new_name" 2>/dev/null; then echo "tmux-util rename: a session named '$new_name' already exists." >&2 return 1 fi tmux rename-session -t "$choice" "$new_name" echo "Renamed: $choice -> $new_name" } # ----------------------------------------------------------------------------- # pick # ----------------------------------------------------------------------------- cmd_pick() { local sessions sessions=$(tmux list-sessions \ -F '#{session_attached} #{session_name}' 2>/dev/null || true) if [ -z "$sessions" ]; then echo "No tmux sessions to pick from." return 0 fi # Format for fzf: " " — name is the second field of the # selection line, which we recover after fzf returns. local choice choice=$(echo "$sessions" | awk '{ state = ($1 == "1") ? "attached" : "detached" printf "%-9s %s\n", state, $2 }' | fzf --header="Pick a tmux session" | awk '{print $2}') if [ -z "$choice" ]; then # User cancelled (Ctrl-C or Esc); fzf exits 130, the pipeline # returns empty. return 0 fi if [ -n "${TMUX:-}" ]; then tmux switch-client -t "$choice" else tmux attach-session -t "$choice" fi } # ----------------------------------------------------------------------------- # find # ----------------------------------------------------------------------------- cmd_find() { local pattern="${1:-}" if [ -z "$pattern" ]; then echo "tmux-util find: missing pattern" >&2 echo "Usage: tmux-util find " >&2 return 2 fi local matches matches=$(tmux list-panes -a \ -F '#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}' \ 2>/dev/null | grep -E "$pattern" || true) if [ -z "$matches" ]; then return 1 fi echo "$matches" } # ----------------------------------------------------------------------------- # go # ----------------------------------------------------------------------------- cmd_go() { local name="${1:-}" if [ -z "$name" ]; then echo "tmux-util go: missing session name" >&2 echo "Usage: tmux-util go " >&2 return 2 fi # `=name` forces exact match in `tmux has-session` (otherwise tmux does # prefix matching, which would let `go foo` attach to a session named # `foobar`). if tmux has-session -t "=$name" 2>/dev/null; then if [ -n "${TMUX:-}" ]; then tmux switch-client -t "$name" else tmux attach-session -t "$name" fi else if [ -n "${TMUX:-}" ]; then tmux new-session -d -s "$name" -c "$PWD" tmux switch-client -t "$name" else tmux new-session -s "$name" -c "$PWD" fi fi } # ----------------------------------------------------------------------------- # ls # ----------------------------------------------------------------------------- # Render seconds as a short human duration (e.g. "5s", "12m", "3h", "2d"). format_idle() { local s="$1" if [ "$s" -lt 60 ]; then echo "${s}s" elif [ "$s" -lt 3600 ]; then echo "$((s / 60))m" elif [ "$s" -lt 86400 ]; then echo "$((s / 3600))h" else echo "$((s / 86400))d" fi } cmd_ls() { local sessions sessions=$(tmux list-sessions \ -F '#{session_attached} #{session_name} #{session_activity} #{session_windows}' \ 2>/dev/null || true) if [ -z "$sessions" ]; then echo "No tmux sessions." return 0 fi local now="${TMUX_UTIL_NOW:-$(date +%s)}" { printf 'STATE\tNAME\tIDLE\tWINDOWS\tCWD\n' local attached name activity windows state idle_str cwd while IFS=' ' read -r attached name activity windows; do [ -n "$name" ] || continue state="detached" [ "$attached" -gt 0 ] && state="attached" idle_str=$(format_idle "$((now - activity))") cwd=$(tmux display -p -t "$name" '#{pane_current_path}' 2>/dev/null || echo "?") # Replace leading $HOME with ~ for display. if [ -n "${HOME:-}" ] && [ "${cwd#$HOME}" != "$cwd" ]; then cwd="~${cwd#$HOME}" fi printf '%s\t%s\t%s\t%s\t%s\n' "$state" "$name" "$idle_str" "$windows" "$cwd" done <<<"$sessions" } | column -t -s $'\t' } # ----------------------------------------------------------------------------- # Reap # ----------------------------------------------------------------------------- cmd_reap() { local skip="${TMUX_UTIL_REAP_SKIP:-^aiv-}" local sessions sessions=$(tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null \ | awk '$2 == 0 {print $1}' \ | grep -vE "$skip" || true) if [ -z "$sessions" ]; then echo "No unattached sessions to reap." return 0 fi local s pids while IFS= read -r s; do [ -n "$s" ] || continue echo "Reaping: $s" pids=$(tmux list-panes -s -t "$s" -F '#{pane_pid}' 2>/dev/null) if [ -n "$pids" ]; then echo "$pids" | xargs -r kill -HUP fi # Give the session a moment to wind down naturally. local i for i in 1 2 3; do tmux has-session -t "$s" 2>/dev/null || break sleep 1 done if tmux has-session -t "$s" 2>/dev/null; then echo " still alive, force killing" tmux kill-session -t "$s" fi done <<<"$sessions" } # ----------------------------------------------------------------------------- # Dispatch # ----------------------------------------------------------------------------- main() { if [ "$#" -eq 0 ]; then usage return 0 fi local sub="$1" shift case "$sub" in -h|--help|help) usage ;; reap) cmd_reap "$@" ;; ls) cmd_ls "$@" ;; go) cmd_go "$@" ;; find) cmd_find "$@" ;; pick) cmd_pick "$@" ;; rename) cmd_rename "$@" ;; *) echo "tmux-util: unknown subcommand: $sub" >&2 usage >&2 return 2 ;; esac } main "$@"