Last active
February 7, 2026 12:55
-
-
Save htlin222/35082bf3a298c8d825b16417dc2a8426 to your computer and use it in GitHub Desktop.
tmux Claude session switcher - fzf-powered fuzzy finder to switch between active Claude panes or resume inactive sessions
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
| #!/bin/zsh -f | |
| # Suppress all stderr during cache building | |
| exec 2>/dev/null | |
| # title: "tmux_claude_switcher" | |
| # version: 3.6.1 - sort inactive by recency (newest first) | |
| # description: Claude pane switcher - active panes fresh, inactive sessions cached | |
| # | |
| # ============================================================================ | |
| # TMUX SETUP TUTORIAL | |
| # ============================================================================ | |
| # | |
| # This script provides a fuzzy-finder interface to switch between Claude | |
| # sessions in tmux. Active panes show with ● and inactive (resumable) | |
| # sessions show with ○. | |
| # | |
| # PREREQUISITES: | |
| # - fzf (brew install fzf) | |
| # - Claude CLI (claude) | |
| # | |
| # INSTALLATION: | |
| # 1. Save this script somewhere in your PATH or a known location, e.g.: | |
| # ~/.dotfiles/shellscripts/tmux_claude_switcher.sh | |
| # | |
| # 2. Make it executable: | |
| # chmod +x ~/.dotfiles/shellscripts/tmux_claude_switcher.sh | |
| # | |
| # 3. Add the following to your ~/.tmux.conf: | |
| # | |
| # # Claude session switcher - fuzzy find and switch between Claude panes | |
| # bind-key C-c run-shell -b "~/.dotfiles/shellscripts/tmux_claude_switcher.sh" | |
| # | |
| # Or with popup (recommended for better UX): | |
| # | |
| # bind-key C-c display-popup -E -w 80% -h 60% "~/.dotfiles/shellscripts/tmux_claude_switcher.sh" | |
| # | |
| # 4. Reload tmux config: | |
| # tmux source-file ~/.tmux.conf | |
| # | |
| # USAGE: | |
| # - Press your prefix (usually Ctrl-b) + Ctrl-c to open the switcher | |
| # - Use arrow keys or type to filter sessions | |
| # - Press Enter to: | |
| # • Switch to an active Claude pane (● green dot) | |
| # • Resume an inactive session in a new window (○ circle) | |
| # | |
| # CUSTOMIZATION: | |
| # - Change ACTIVE_TTL (line ~12) to adjust cache refresh for active panes | |
| # - Change INACTIVE_TTL (line ~13) to adjust cache refresh for inactive sessions | |
| # - Modify the keybinding in tmux.conf to your preference (e.g., bind-key M-c) | |
| # | |
| # ============================================================================ | |
| session=$(tmux display-message -p '#S') | |
| CLAUDE_DIR="$HOME/.claude/projects" | |
| CACHE_DIR="/tmp/tmux_claude_cache" | |
| ACTIVE_CACHE="$CACHE_DIR/${session}_active_v1" | |
| INACTIVE_CACHE="$CACHE_DIR/${session}_inactive_v2" | |
| TOPIC_CACHE="$CACHE_DIR/topics_v4" | |
| ACTIVE_TTL=10 | |
| INACTIVE_TTL=30 | |
| mkdir -p "$CACHE_DIR" | |
| # Colors | |
| C_CYAN=$'\033[36m' | |
| C_YELLOW=$'\033[33m' | |
| C_MAGENTA=$'\033[35m' | |
| C_GREEN=$'\033[32m' | |
| C_DIM=$'\033[2m' | |
| C_RESET=$'\033[0m' | |
| # Format relative time (3 char): 2m, 5h, 3d, 2w, 3M | |
| rel_time() { | |
| local secs=$1 | |
| (( secs < 60 )) && { printf "%2ds" $secs; return; } | |
| (( secs < 3600 )) && { printf "%2dm" $((secs/60)); return; } | |
| (( secs < 86400 )) && { printf "%2dh" $((secs/3600)); return; } | |
| (( secs < 604800 )) && { printf "%2dd" $((secs/86400)); return; } | |
| (( secs < 2592000 )) && { printf "%2dw" $((secs/604800)); return; } | |
| printf "%2dM" $((secs/2592000)) | |
| } | |
| # Get topic and branch from JSONL (cached) | |
| get_info() { | |
| local jsonl_file="$1" | |
| local session_id="${jsonl_file:t:r}" | |
| if [[ -f "$TOPIC_CACHE" ]]; then | |
| local cached=$(grep "^${session_id} " "$TOPIC_CACHE" 2>/dev/null | cut -f2-) | |
| [[ -n "$cached" ]] && { echo "$cached"; return; } | |
| fi | |
| local line=$(grep '"type":"user"' "$jsonl_file" 2>/dev/null | head -1) | |
| [[ -z "$line" ]] && return | |
| local topic=$(echo "$line" | grep -o '"content":"[^"]*"' | head -1 | cut -d'"' -f4) | |
| local branch=$(echo "$line" | grep -o '"gitBranch":"[^"]*"' | cut -d'"' -f4) | |
| local cwd=$(echo "$line" | grep -o '"cwd":"[^"]*"' | cut -d'"' -f4) | |
| [[ "$topic" == "<"* ]] && return | |
| [[ -n "$topic" ]] && echo "${session_id} ${topic} ${branch} ${cwd}" >> "$TOPIC_CACHE" | |
| echo "${topic} ${branch} ${cwd}" | |
| } | |
| # === ACTIVE PANES (10s cache) === | |
| typeset -A active_cwds | |
| typeset -a active_items | |
| max_id=0 | |
| max_dir=15 | |
| max_branch=15 | |
| use_active_cache=0 | |
| if [[ -f "$ACTIVE_CACHE" ]]; then | |
| mtime=$(stat -c %Y "$ACTIVE_CACHE" 2>/dev/null || stat -f %m "$ACTIVE_CACHE" 2>/dev/null || echo 0) | |
| (( $(date +%s) - mtime <= ACTIVE_TTL )) && use_active_cache=1 | |
| fi | |
| if (( use_active_cache )); then | |
| while IFS= read -r line; do | |
| active_items+=("$line") | |
| IFS='|' read -r id dir branch topic cwd <<< "$line" | |
| active_cwds[$cwd]=1 | |
| (( ${#id} > max_id )) && max_id=${#id} | |
| done < "$ACTIVE_CACHE" | |
| else | |
| typeset -A claude_pids | |
| while read -r pid; do | |
| ppid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') | |
| [[ -n "$ppid" ]] && claude_pids[$ppid]=1 | |
| gppid=$(ps -o ppid= -p "$ppid" 2>/dev/null | tr -d ' ') | |
| [[ -n "$gppid" ]] && claude_pids[$gppid]=1 | |
| done < <(pgrep -f '[c]laude' 2>/dev/null) | |
| cache_content="" | |
| while IFS='|' read -r pane_pid pane_id pane_path; do | |
| if [[ -n "${claude_pids[$pane_pid]}" ]]; then | |
| active_cwds[$pane_path]=1 | |
| dir="${pane_path:t}" | |
| project_dir="${pane_path//\//-}" | |
| session_dir="$CLAUDE_DIR/$project_dir" | |
| latest=$(/bin/ls -t "$session_dir"/*.jsonl 2>/dev/null | head -1) | |
| topic="" branch="" | |
| if [[ -n "$latest" ]]; then | |
| info=$(get_info "$latest") | |
| topic=$(echo "$info" | cut -f1) | |
| branch=$(echo "$info" | cut -f2) | |
| fi | |
| item="${pane_id}|${dir}|${branch}|${topic}|${pane_path}" | |
| active_items+=("$item") | |
| cache_content+="$item"$'\n' | |
| (( ${#pane_id} > max_id )) && max_id=${#pane_id} | |
| fi | |
| done < <(tmux list-panes -s -t "$session" -F '#{pane_pid}|#{window_index}:#{pane_index}|#{pane_current_path}') | |
| echo -n "$cache_content" > "$ACTIVE_CACHE" | |
| fi | |
| active_count=${#active_items[@]} | |
| # === INACTIVE SESSIONS (cached) === | |
| typeset -a inactive_items | |
| inactive_count=0 | |
| use_inactive_cache=0 | |
| if [[ -f "$INACTIVE_CACHE" ]]; then | |
| mtime=$(stat -c %Y "$INACTIVE_CACHE" 2>/dev/null || stat -f %m "$INACTIVE_CACHE" 2>/dev/null || echo 0) | |
| (( $(date +%s) - mtime <= INACTIVE_TTL )) && use_inactive_cache=1 | |
| fi | |
| if (( use_inactive_cache )); then | |
| while IFS= read -r line; do | |
| # Format: short_id|dir|branch|topic|session_id|cwd|mtime | |
| inactive_items+=("$line") | |
| cwd=$(echo "$line" | cut -d'|' -f6) | |
| # Skip if now active | |
| [[ -n "${active_cwds[$cwd]}" ]] && continue | |
| short_id=$(echo "$line" | cut -d'|' -f1) | |
| (( ${#short_id} > max_id )) && max_id=${#short_id} | |
| ((inactive_count++)) | |
| done < "$INACTIVE_CACHE" | |
| else | |
| # Rebuild inactive cache (suppress glob errors) | |
| cache_content="" | |
| setopt NULL_GLOB 2>/dev/null | |
| for project_dir in "$CLAUDE_DIR"/*(/Nom[1,15]); do | |
| (( inactive_count >= 15 )) && break | |
| latest=$(/bin/ls -t "$project_dir"/*.jsonl 2>/dev/null | head -1) | |
| [[ -z "$latest" ]] && continue | |
| info=$(get_info "$latest") | |
| [[ -z "$info" ]] && continue | |
| topic=$(echo "$info" | cut -f1) | |
| branch=$(echo "$info" | cut -f2) | |
| cwd=$(echo "$info" | cut -f3) | |
| [[ -n "${active_cwds[$cwd]}" ]] && continue | |
| # Skip if directory doesn't exist on this machine | |
| [[ ! -d "$cwd" ]] && continue | |
| session_id="${latest:t:r}" | |
| dir="${cwd:t}" | |
| short_id="${session_id:0:8}" | |
| file_mtime=$(stat -f %m "$latest" 2>/dev/null || stat -c %Y "$latest" 2>/dev/null || echo 0) | |
| item="${short_id}|${dir}|${branch}|${topic}|${session_id}|${cwd}|${file_mtime}" | |
| inactive_items+=("$item") | |
| cache_content+="$item"$'\n' | |
| (( ${#short_id} > max_id )) && max_id=${#short_id} | |
| ((inactive_count++)) | |
| done | |
| # Sort and save cache (newest first) | |
| echo -n "$cache_content" | sort -t'|' -k7 -rn > "$INACTIVE_CACHE" | |
| # Reload sorted | |
| inactive_items=() | |
| while IFS= read -r line; do | |
| [[ -n "$line" ]] && inactive_items+=("$line") | |
| done < "$INACTIVE_CACHE" | |
| fi | |
| # === BUILD OUTPUT === | |
| output="" | |
| # Truncate helper: truncate string to max length | |
| trunc() { [[ ${#1} -gt $2 ]] && echo "${1:0:$((2-1))}…" || echo "$1"; } | |
| # Active panes | |
| for item in "${active_items[@]}"; do | |
| IFS='|' read -r id dir branch topic cwd <<< "$item" | |
| dir=$(trunc "$dir" $max_dir) | |
| branch=$(trunc "$branch" $max_branch) | |
| line="${C_GREEN}●${C_RESET} " | |
| line+="${C_CYAN}$(printf "%-${max_id}s" "$id")${C_RESET} " | |
| line+="${C_YELLOW}$(printf "%-${max_dir}s" "$dir")${C_RESET}" | |
| if (( max_branch > 0 )); then | |
| if [[ -n "$branch" ]]; then | |
| line+=" ${C_MAGENTA}$(printf "%-${max_branch}s" "$branch")${C_RESET}" | |
| else | |
| line+=" $(printf "%-${max_branch}s" "")" | |
| fi | |
| fi | |
| [[ -n "$topic" ]] && line+=" ${C_DIM}${topic}${C_RESET}" | |
| output+="$line"$'\n' | |
| done | |
| # Separator | |
| if (( active_count > 0 && inactive_count > 0 )); then | |
| output+="${C_DIM}──── inactive ────${C_RESET}"$'\n' | |
| fi | |
| # Inactive sessions | |
| now=$(date +%s) | |
| for item in "${inactive_items[@]}"; do | |
| IFS='|' read -r short_id dir branch topic session_id cwd file_mtime <<< "$item" | |
| # Skip if now active | |
| [[ -n "${active_cwds[$cwd]}" ]] && continue | |
| dir=$(trunc "$dir" $max_dir) | |
| branch=$(trunc "$branch" $max_branch) | |
| age=$((now - file_mtime)) | |
| time_str=$(rel_time $age) | |
| line="${C_DIM}○${C_RESET} " | |
| line+="${C_CYAN}$(printf "%-${max_id}s" "$short_id")${C_RESET} " | |
| line+="${C_DIM}${time_str}${C_RESET} " | |
| line+="${C_YELLOW}$(printf "%-${max_dir}s" "$dir")${C_RESET}" | |
| if (( max_branch > 0 )); then | |
| if [[ -n "$branch" ]]; then | |
| line+=" ${C_MAGENTA}$(printf "%-${max_branch}s" "$branch")${C_RESET}" | |
| else | |
| line+=" $(printf "%-${max_branch}s" "")" | |
| fi | |
| fi | |
| [[ -n "$topic" ]] && line+=" ${C_DIM}${topic}${C_RESET}" | |
| line+=" ${session_id} ${cwd}" | |
| output+="$line"$'\n' | |
| done | |
| output="${output%$'\n'}" | |
| if [[ -z "$output" ]]; then | |
| tmux display-message "No Claude sessions" | |
| exit 0 | |
| fi | |
| selected=$(echo "$output" | fzf \ | |
| --ansi \ | |
| --no-info \ | |
| --header="Claude [${active_count} active, ${inactive_count} inactive]" \ | |
| --header-first \ | |
| --border=none \ | |
| --prompt="> " \ | |
| --height=100% \ | |
| --layout=reverse \ | |
| --preview-window=hidden \ | |
| --with-nth=1 \ | |
| --delimiter=$'\t') | |
| if [[ -n "$selected" ]]; then | |
| [[ "$selected" == *"inactive"* ]] && exit 0 | |
| clean=$(echo "$selected" | sed 's/\x1b\[[0-9;]*m//g') | |
| if [[ "$clean" == ●* ]]; then | |
| target=$(echo "$clean" | awk '{print $2}') | |
| win="${target%%:*}" | |
| pane="${target##*:}" | |
| tmux select-window -t "$session:$win" | |
| tmux select-pane -t "$session:$win.$pane" | |
| else | |
| session_id=$(echo "$selected" | cut -f2) | |
| cwd=$(echo "$selected" | cut -f3) | |
| tmux new-window -c "$cwd" -n "claude" "claude --resume $session_id --dangerously-skip-permissions" | |
| fi | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment