#!/usr/bin/env bash # Define color variables CSI="\e[" RESET="${CSI}0m" BOLD_BLUE="${CSI}1;34m" YELLOW="${CSI}0;33m" GREEN="${CSI}0;32m" MAGENTA="${CSI}0;35m" CYAN="${CSI}0;36m" DIM="${CSI}2m" help() { local content=`cat << EOF ${BOLD_BLUE}tmuxss${RESET} - a simple tmux multiview session manager ${CYAN}Syntax:${RESET} tmuxss -<${YELLOW}k|a|i|c${RESET}> [-${YELLOW}g${RESET} group] [-${YELLOW}s${RESET} sid] [-${YELLOW}d${RESET}] [-${YELLOW}t${RESET} template] [-${YELLOW}p${RESET} path] ${BOLD_BLUE}Commands:${RESET} ${YELLOW}k${RESET} Kill sessions associated with a SID or group ${YELLOW}a${RESET} Attach to a session group with the given root SID ${YELLOW}i${RESET} Interactive open mode ${YELLOW}c${RESET} Create a session group in the current directory or a provided one, optionally using a template ${YELLOW}p${RESET} A directory path to create the session in ${YELLOW}b${RESET} Initialize the main session ${YELLOW}h${RESET} Print the entire help menu ${BOLD_BLUE}Options:${RESET} ${YELLOW}s${RESET} SID (Session ID) ${YELLOW}g${RESET} Group name ${YELLOW}t${RESET} Template name ${YELLOW}f${RESET} Path to template config file ${YELLOW}d${RESET} Stay detached from new session EOF ` printf "$content" echo echo } extended_help() { content=`cat << EOF ${BOLD_BLUE}Config Format:${RESET} ${CYAN}{${RESET} ${YELLOW}"default"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: \$PWD${RESET} ${YELLOW}"envs"${RESET}: ${CYAN}{${RESET} ${GREEN}""${RESET}: ${CYAN}{${RESET} ${YELLOW}"path"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: \$PWD${RESET} ${YELLOW}"group"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: basename \$PWD${RESET} ${YELLOW}"focused"${RESET}: ${CYAN}${RESET}, ${DIM}# default: 0${RESET} ${YELLOW}"windows"${RESET}: ${CYAN}[${RESET} ${CYAN}{${RESET} ${YELLOW}"name"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: index${RESET} ${YELLOW}"path"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: \$PWD${RESET} ${YELLOW}"command_run"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: ""${RESET} ${YELLOW}"command_prepare"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: ""${RESET} ${YELLOW}"read_only"${RESET}: ${MAGENTA}true${RESET} | ${MAGENTA}false${RESET}, ${DIM}# default: false${RESET} ${YELLOW}"hist_file"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: std Hist${RESET} ${YELLOW}"hsplit"${RESET}: ${CYAN}[ , ...]${RESET}, ${YELLOW}"vsplit"${RESET}: ${CYAN}[ , ...]${RESET} ${CYAN}}, ...]${RESET} ${CYAN}}${RESET} ${CYAN}}${RESET} ${CYAN}}${RESET} ${BOLD_BLUE}Pane:${RESET} ${CYAN}{${RESET} ${YELLOW}"path"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: \$PWD${RESET} ${YELLOW}"command_run"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: ""${RESET} ${YELLOW}"command_prepare"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: ""${RESET} ${YELLOW}"read_only"${RESET}: ${MAGENTA}true${RESET} | ${MAGENTA}false${RESET}, ${DIM}# default: false${RESET} ${YELLOW}"hist_file"${RESET}: ${GREEN}""${RESET}, ${DIM}# default: std Hist${RESET} ${YELLOW}"hsplit"${RESET}?: ${CYAN}[ , ...]${RESET}, ${YELLOW}"vsplit"${RESET}?: ${CYAN}[ , ...]${RESET} ${CYAN}}${RESET} ${BOLD_BLUE}Notes:${RESET} - All paths are relative to the environment path unless absolute - Only either hsplit or vsplit may be defined ${BOLD_BLUE}Automatic Template Resolution (resolve key):${RESET} ${CYAN}[${RESET} ${CYAN}{${RESET} ${YELLOW}"path"${RESET}: ${GREEN}""${RESET}, ${DIM}# Path to search for this template${RESET} ${YELLOW}"template"${RESET}: ${GREEN}""${RESET}, ${DIM}# Template to automatically select if path matches${RESET} ${CYAN}}, ...${RESET} ${CYAN}]${RESET} ${BOLD_BLUE}Notes:${RESET} - Paths can be absolute or relative to \$HOME - When creating a session, tmuxss will check if the current path matches any 'resolve' entry - If a match is found, the corresponding template is selected automatically - Multiple entries are checked in order; first match wins ${BOLD_BLUE}Example:${RESET} ${CYAN}[${RESET} ${CYAN}{${RESET} "path": "~/src/autopurger", "template": "autopurger" ${CYAN}},${RESET} ${CYAN}{${RESET} "path": "~/src/project-x", "template": "projectx" ${CYAN}}${RESET} ${CYAN}]${RESET} ${BOLD_BLUE}Examples:${RESET} ${YELLOW}Basic layout:${RESET} ${CYAN}{${RESET} ${YELLOW}"default"${RESET}: ${GREEN}"default"${RESET}, ${YELLOW}"envs"${RESET}: ${CYAN}{${RESET} ${GREEN}"default"${RESET}: ${CYAN}{${RESET} ${YELLOW}"path"${RESET}: ${GREEN}"."${RESET}, ${YELLOW}"focused"${RESET}: ${CYAN}0${RESET}, ${YELLOW}"windows"${RESET}: ${CYAN}[${RESET} ${CYAN}{${RESET} ${YELLOW}"name"${RESET}: ${GREEN}"code"${RESET}, ${YELLOW}"command_run"${RESET}: ${GREEN}"nvim"${RESET} ${CYAN}},${RESET} ${CYAN}{${RESET} ${YELLOW}"name"${RESET}: ${GREEN}"util"${RESET}, ${YELLOW}"command_run"${RESET}: ${GREEN}"htop"${RESET} ${CYAN}}${RESET} ${CYAN}]${RESET} ${CYAN}}${RESET} ${CYAN}}${RESET} ${CYAN}}${RESET} ${YELLOW}Nested splits with preparation:${RESET} ${CYAN}{${RESET} ${YELLOW}"envs"${RESET}: ${CYAN}{${RESET} ${GREEN}"dev"${RESET}: ${CYAN}{${RESET} ${YELLOW}"path"${RESET}: ${GREEN}"~/src/project"${RESET}, ${YELLOW}"windows"${RESET}: ${CYAN}[${RESET} ${CYAN}{${RESET} ${YELLOW}"name"${RESET}: ${GREEN}"code"${RESET}, ${YELLOW}"command_run"${RESET}: ${GREEN}"nvim"${RESET} ${CYAN}},${RESET} ${CYAN}{${RESET} ${YELLOW}"name"${RESET}: ${GREEN}"runtime"${RESET}, ${YELLOW}"hsplit"${RESET}: ${CYAN}[${RESET} ${CYAN}{${RESET} ${YELLOW}"vsplit"${RESET}: ${CYAN}[${RESET} ${CYAN}{${RESET} ${YELLOW}"command_prepare"${RESET}: ${GREEN}"yarn start"${RESET}, ${YELLOW}"path"${RESET}: ${GREEN}"./frontend"${RESET} ${CYAN}},${RESET} ${CYAN}{${RESET} ${YELLOW}"command_prepare"${RESET}: ${GREEN}"cargo run"${RESET}, ${YELLOW}"path"${RESET}: ${GREEN}"./backend"${RESET} ${CYAN}}${RESET} ${CYAN}]${RESET} ${CYAN}},${RESET} ${CYAN}{${RESET} ${YELLOW}"command_run"${RESET}: ${GREEN}"htop"${RESET} ${CYAN}}${RESET} ${CYAN}]${RESET} ${CYAN}}${RESET} ${CYAN}]${RESET} ${CYAN}}${RESET} ${CYAN}}${RESET} ${CYAN}}${RESET} EOF ` printf "$content" echo } resolve_path() { local base=$1 local pair=$2 [[ -z $pair ]] && pair="." [[ $pair == ~* ]] && pair="${pair/#\~/$HOME}" if [[ $pair = /* ]]; then realpath "$pair" else realpath "$base/$pair" fi } SID="" GROUP="" SUBCOMMAND="" TEMPLATE="" DETACH=0 BASE_PATH=$PWD YQ_AVAILABLE=$(command -v yq >/dev/null 2>&1 && echo true || echo false) TEMPLATE_SOURCE="${XDG_DATA_HOME:-$HOME/.config}/tmux/tmuxss.json" while getopts "p:f:g:t:s:bhkaicd" option; do case $option in h) help extended_help exit 0 ;; s) if [[ $OPTARG =~ "#" ]]; then echo "\"#\" is a reserved character" exit 3 fi SID=$OPTARG ;; g) if [[ $OPTARG =~ "#" ]]; then echo "\"#\" is a reserved character" exit 3 fi GROUP=$OPTARG ;; t) if [[ ! $OPTARG =~ ^[a-zA-Z]+$ ]]; then echo "Template names must be only upper and lowercase." exit 3 fi # GROUP=$OPTARG TEMPLATE=$OPTARG ;; f) if [[ ! $OPTARG =~ ^(/)?([^/\0]+(/)?)+$ ]]; then echo "Template source must be a valid path." exit 3 fi TEMPLATE_SOURCE=$OPTARG ;; p) if [[ ! $OPTARG =~ ^(/)?([^/\0]+(/)?)+$ ]]; then echo "Path must be valid." exit 3 fi BASE_PATH=$(resolve_path $BASE_PATH $OPTARG);; i) SUBCOMMAND="i" ;; a) SUBCOMMAND="a" ;; k) SUBCOMMAND="k" ;; b) SUBCOMMAND="b" ;; c) SUBCOMMAND="c" ;; d) DETACH=1 ;; ?) echo "Invalid option" echo "Pull up the help menu with tmuxss -h" exit 3 ;; esac done ATTACHED_TO="" SIDINFERED=0 infer() { if [[ -z $SID ]]; then if [[ -n "$TMUX" ]]; then local SESSION=$(tmux display-message -p '#S') ATTACHED_TO=$SESSION SID=${SESSION#*#} if [[ -z $SID ]]; then echo "No ID found in the current tmux session name: $SESSION" exit 66 fi SIDINFERED=1 fi fi } infer attachSession() { local group=$1 local sid=$2 local session="$group#$sid" # Check if a session exists in the group with the current SID if [[ -z $(tmux list-sessions -F "#{session_name}" | grep "^$session$") ]]; then # If no session exists with the current SID, create a new one tmux new-session -ds "$session" -t "$group" fi if [[ $TMUX ]]; then tmux switch-client -t "$session" else tmux attach -t "$session" fi } sanitize_path() { local input=$1 local sanitized=$(echo "$input" | tr -d '.' | sed 's/[^a-zA-Z0-9]/_/g' | tr '[:upper:]' '[:lower:]') echo "$sanitized" } resolve_template_from_path() { # explicit -t always wins [[ -n $TEMPLATE ]] && return local best_template="" local best_len=0 local i=0 while :; do local path_var="resolve_${i}_path" local tmpl_var="resolve_${i}_template" # stop when index no longer exists in parsed DATA grep -q "^${path_var}=" <<< "$DATA" || break local raw_path=${!path_var} local tmpl=${!tmpl_var} # normalize candidate path local abs_path abs_path=$(resolve_path "$HOME" "$raw_path") # prefix match if [[ $BASE_PATH == "$abs_path"* ]]; then local len=${#abs_path} if (( len > best_len )); then best_len=$len best_template=$tmpl fi fi ((i++)) done [[ -n $best_template ]] && TEMPLATE="$best_template" } # This gets the last bit of performance setup_pane() { local key="$1" local pane_id="$2" if [[ $SHELL != "/bin/fish" ]]; then local hist_file="${key}_hist_file" hist_file=${!hist_file} # Set HISTFILE only if a specific file was provided if [[ -n $hist_file ]]; then hist_file=$(resolve_path "$BASE_PATH" "$hist_file") tmux send-keys -t "$pane_id" "export HISTFILE=\"$hist_file\"; export PROMPT_COMMAND='history -a; history -c; history -r'; history -d \$(history 1)" C-m fi local read_only="${key}_read_only" read_only=${!read_only} if [[ $read_only == "true" ]]; then tmux send-keys -t "$pane_id" "shopt -ou history; history -d \$(history 1)" C-m fi tmux send-keys -t "$pane_id" "clear; history -d \$(history 1)" C-m tmux clear-history -t "$pane_id" fi local command_run="${key}_command_run" command_run=${!command_run} [[ -n $command_run ]] && tmux send-keys -t "$pane_id" "$command_run" C-m local command_prepare="${key}_command_prepare" command_prepare=${!command_prepare} [[ -n $command_prepare ]] && tmux send-keys -t "$pane_id" "$command_prepare" } visit_pane() { local key="$1" local window_index="$2" local pane_index="${PANE_INDEX[$window_index]:-0}" local path_var="${key}_path" local path="${!path_var}" path=$(resolve_path "$BASE_PATH" "$path") local pane_id="$GROUP:$window_index.$pane_index" local hsplit="${key}_hsplit" local vsplit="${key}_vsplit" if grep -q "$hsplit" <<< "$DATA"; then tmux split-window -h -t "$pane_id" -c "$path" local i=0 while grep -q "${hsplit}_${i}" <<< "$DATA"; do visit_pane "${hsplit}_${i}" "$window_index" "$pane_index" ((i++)) done elif grep -q "$vsplit" <<< "$DATA"; then tmux split-window -v -t "$pane_id" -c "$path" local i=0 while grep -q "${vsplit}_${i}" <<< "$DATA"; do visit_pane "${vsplit}_${i}" "$window_index" "$pane_index" ((i++)) done else # Ensure the pane exists at the correct cwd before setup tmux respawn-pane -k -t "$pane_id" -c "$path" setup_pane "$key" "$pane_id" & PANE_INDEX[$window_index]=$((pane_index + 1)) fi } DATA="" declare -A PANE_INDEX build_template() { local path="${template_key}_path" path=${!path} BASE_PATH=$(resolve_path $BASE_PATH $path) cd "$BASE_PATH" && tmux new-session -ds "$GROUP" -c "$BASE_PATH" local i=0 while :; do local key="${template_key}_windows_${i}" if [ $i -gt 0 ] && ! grep -q "$key" <<< "$DATA"; then break; fi local name="${key}_name" name=${!name:-$i} [[ $i -gt 0 ]] && tmux new-window -d -t "$GROUP" -c "$BASE_PATH" -n "$name" visit_pane "$key" "$i" & i=$((i + 1)) done wait local focused_window="${template_key}_focused" focused_window=${!focused_window} [[ -z $focused_window ]] && focused_window="0" tmux select-window -t "$GROUP:$focused_window" } case $SUBCOMMAND in k) if [[ -n $GROUP ]]; then SESSION_LIST=$(tmux list-sessions -F "#{session_name}") for SESSION in $SESSION_LIST; do if [[ $SESSION == "$GROUP#"* || $SESSION == $GROUP ]]; then if [[ "$GROUP#$SID" == $ATTACHED_TO && $GROUP != "main" ]]; then attachSession main $SID fi tmux kill-session -t "$SESSION" fi done fi if [[ -n $SID && $SIDINFERED -eq 0 ]]; then SESSION_LIST=$(tmux list-sessions -F "#{session_name}") for SESSION in $SESSION_LIST; do if [[ $SESSION == *#$SID ]]; then tmux kill-session -t "$SESSION" fi done fi if [[ ( -n $SID && $SIDINFERED -eq 0 ) || -n $GROUP ]]; then exit 0 fi ;; a) if [[ -z $GROUP ]]; then echo "No group specified" echo "Check out tmuxss -h" exit 3 fi [[ -z $SID ]] && SID=$$ attachSession $GROUP $SID exit 0 ;; i) file=$(<"$TEMPLATE_SOURCE") if [[ -n $file ]] && $YQ_AVAILABLE; then DATA=$(yq -p=json -o shell <<< "$file") || { echo "Invalid template file" exit 3 } fi eval "$DATA" SEARCH_PATHS=() mapfile -t SEARCH_PATHS < <(yq -r '.paths[]?' <<< "$file") for i in "${!SEARCH_PATHS[@]}"; do p="${SEARCH_PATHS[$i]}" [[ $p == ~* ]] && p="${p/#\~/$HOME}" [[ $p != /* ]] && p="$PWD/$p" SEARCH_PATHS[$i]="$p" done mapfile -d '' DIRS < <( for path in "${SEARCH_PATHS[@]}"; do [[ -d "$path" ]] || continue find "$path" -mindepth 1 -maxdepth 1 -type d -print0 done ) [[ ${#DIRS[@]} -eq 0 ]] && { echo "No directories found in SEARCH_PATHS"; exit 0; } selected=$(printf '%s\n' "${DIRS[@]}" | fzf --height 40% --reverse) [[ -z "${selected:-}" ]] && exit 0 selected=${selected%$'\0'} exec tmuxss -c -p "$selected" ;; b) [[ -z $SID ]] && SID=$$ tmuxss -c -d -t "main" -g "main" -s "$SID" trap "tmuxss -k -s $SID" EXIT SIGHUP && tmuxss -a -g "main" -s "$SID" exit 0 ;; c) file=$(<"$TEMPLATE_SOURCE") if [[ -n $file ]] && $YQ_AVAILABLE; then DATA=$(yq -p=json -o shell <<< "$file") || { echo "Invalid template file" exit 3 } fi if [[ -z $DATA ]]; then if [[ -n $TEMPLATE && $TEMPLATE != "main" ]]; then ! $YQ_AVAILABLE && { echo "Using templates requires yq to be installed"; exit 1; } echo "Could not locate config file" exit 1 fi DATA="default='default' envs_default_path='.' envs_main_path='~' envs_main_group='main'" fi eval "$DATA" resolve_template_from_path if [[ -z $TEMPLATE ]]; then default_template="default" default_template=${!default_template} [[ -n $default_template ]] && TEMPLATE=$default_template fi template_key="envs_${TEMPLATE}" if ! grep -q "$template_key" <<< "$DATA"; then { echo "Session template not found"; exit 3; }; fi template_group="${template_key}_group" template_group=${!template_group} [[ -n $template_group ]] && GROUP=$template_group if [[ -z $GROUP ]]; then GROUP=$(sanitize_path "$(basename "$BASE_PATH")") # If the current directory is root, set GROUP to "root" if [[ $GROUP == "/" ]]; then GROUP="root" fi fi [[ -z $SID ]] && SID=$$ if [[ -z $(tmux list-sessions -F "#{session_name}" | grep "^$GROUP$") ]]; then build_template # nohup build_template >/dev/null 2>&1 & fi if [[ $DETACH -eq 0 ]]; then attachSession $GROUP $SID fi exit 0 ;; esac if [[ -n $TMUX ]]; then if [[ -z $SUBCOMMAND || $SUBCOMMAND == "a" ]]; then SUBCOMMAND="a" TITLE="Select to attach" fi if [[ $SUBCOMMAND == "k" ]]; then TITLE="Select to kill" fi ITEMS="" index=1 SESSION_GROUPS=$(tmux list-sessions -F "#{session_name}" | awk -F '#' '{print $1}' | sort -u) while read -r group; do if [[ -n "$group" ]]; then ITEMS+="$group $index 'run-shell \"tmuxss -$SUBCOMMAND -g \"$group\"\"' " ((index++)) fi done <<< "$SESSION_GROUPS" if [[ -n $ITEMS ]]; then eval "tmux display-menu -T \"$TITLE\" $ITEMS" else tmux display-message "No session groups found." fi else help fi