Last active
February 21, 2026 13:44
-
-
Save gatopeich/2b4e805f44791fc817bc043eb82ecaac to your computer and use it in GitHub Desktop.
Claude Code Chooser - TUI project picker and launcher for Claude Code
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/bash | |
| # Claude Code Chooser - TUI project picker and launcher for Claude Code | |
| # Shows recent Claude projects, lets you pick model/effort/mode, then launches claude. | |
| # | |
| # https://gist.github.com/gatopeich/2b4e805f44791fc817bc043eb82ecaac | |
| # Author: gatopeich @ github | |
| # | |
| # Usage: claude-chooser.sh | |
| # | |
| # Kitty terminal integration: | |
| # # Desktop launcher (~/.local/share/applications/claude-kitty.desktop): | |
| # Exec=kitty --class claude-kitty -T "Claude Code" --session ~/.config/kitty/claude-session.conf | |
| # | |
| # # Session file (~/.config/kitty/claude-session.conf) - new tabs also open chooser: | |
| # launch /path/to/claude-chooser.sh | |
| # | |
| # Optional: set window icon on X11 with xseticon.py: | |
| # https://gist.github.com/gatopeich/9d0c61cae0a4e01b23b0afc543c27fbb | |
| # | |
| # Requires: dialog, claude, python3 (for session discovery) | |
| ICON="$HOME/.local/share/icons/claude-code.png" | |
| EXTRA_FILE="$HOME/.config/claude-chooser-extra-args" | |
| # Dialog theme: override color7 (WHITE) to actual white for dialog backgrounds | |
| printf '\033]4;7;#f0f0f0\007' | |
| printf '\033]4;15;#ffffff\007' | |
| export DIALOGRC="$HOME/.config/claude-chooser-dialogrc" | |
| # Extract recent project paths from Claude sessions, sorted by most recent session activity | |
| DIRS=$(python3 -c " | |
| import json, glob, os | |
| from datetime import datetime, timezone | |
| def decode_project_path(dirname): | |
| '''Decode project dir name to real path by greedy shortest-segment matching.''' | |
| encoded = dirname.lstrip('-') | |
| path = '/' | |
| rest = encoded | |
| while rest: | |
| parts = rest.split('-') | |
| best = None | |
| for i in range(1, len(parts) + 1): | |
| candidate = '-'.join(parts[:i]) | |
| if os.path.isdir(os.path.join(path, candidate)): | |
| best = candidate | |
| rest = '-'.join(parts[i:]) | |
| break | |
| if best is None: | |
| best = parts[0] | |
| rest = '-'.join(parts[1:]) | |
| path = os.path.join(path, best) | |
| return path | |
| projects = [] | |
| base = os.path.expanduser('~/.claude/projects') | |
| for d in os.listdir(base): | |
| dpath = os.path.join(base, d) | |
| if not os.path.isdir(dpath): | |
| continue | |
| mtime = 0 | |
| idx = os.path.join(dpath, 'sessions-index.json') | |
| if os.path.exists(idx): | |
| try: | |
| data = json.load(open(idx)) | |
| entries = data.get('entries', []) | |
| if entries: | |
| path = entries[0].get('projectPath', '') | |
| ts = max(e.get('modified', '') for e in entries) | |
| mtime = datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp() | |
| if path: | |
| projects.append((mtime, path)) | |
| continue | |
| except: pass | |
| # Fallback: use most recent .jsonl mtime | |
| jsonls = glob.glob(os.path.join(dpath, '*.jsonl')) | |
| if jsonls: | |
| mtime = max(os.path.getmtime(f) for f in jsonls) | |
| path = decode_project_path(d) | |
| projects.append((mtime, path)) | |
| seen = set() | |
| for _, path in sorted(projects, reverse=True): | |
| if path not in seen: | |
| seen.add(path) | |
| print(path) | |
| " 2>/dev/null) | |
| # Browse directories using dialog menus | |
| browse_dir() { | |
| local cur="${1:-$HOME}" | |
| while true; do | |
| local items=(">>> SELECT THIS FOLDER <<<" "$cur" ".." "Parent directory") | |
| while IFS= read -r d; do | |
| items+=("$(basename "$d")/" "") | |
| done < <(find "$cur" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort) | |
| local pick=$(dialog --clear --title "Browse: $cur" \ | |
| --menu "Choose directory:" 0 0 0 \ | |
| "${items[@]}" \ | |
| 3>&1 1>&2 2>&3) || return 1 | |
| if [ "$pick" = ">>> SELECT THIS FOLDER <<<" ]; then | |
| echo "$cur" | |
| return 0 | |
| elif [ "$pick" = ".." ]; then | |
| cur=$(dirname "$cur") | |
| else | |
| cur="$cur/${pick%/}" | |
| fi | |
| done | |
| } | |
| # State | |
| DANGEROUS=off | |
| EXTRA=$(cat "$EXTRA_FILE" 2>/dev/null || echo "--continue") | |
| while true; do | |
| # Build single menu | |
| MENU=() | |
| i=0 | |
| while IFS= read -r d; do | |
| [ -n "$d" ] && MENU+=("$d" "") && ((i++)) | |
| [ $i -ge 10 ] && break | |
| done <<< "$DIRS" | |
| MENU+=("Browse..." "") | |
| MENU+=("---" "─────────────────────") | |
| if [ "$DANGEROUS" = "on" ]; then | |
| MENU+=("[X] Dangerous mode" "skip all permissions") | |
| else | |
| MENU+=("[ ] Dangerous mode" "skip all permissions") | |
| fi | |
| if [ -n "$EXTRA" ]; then | |
| MENU+=("EXTRA" "args: $EXTRA") | |
| else | |
| MENU+=("EXTRA" "args...") | |
| fi | |
| PICK=$(dialog --clear --title "Claude Code" \ | |
| --menu "Select project to launch, or toggle options:" 0 0 0 \ | |
| "${MENU[@]}" \ | |
| 3>&1 1>&2 2>&3) || exit 1 | |
| case "$PICK" in | |
| "---") continue ;; | |
| *"Dangerous mode") | |
| [ "$DANGEROUS" = "off" ] && DANGEROUS=on || DANGEROUS=off | |
| continue ;; | |
| "EXTRA") | |
| EXTRA=$(dialog --clear --title "Extra args" \ | |
| --inputbox "Claude arguments (e.g. --continue --model opus):" 8 60 "$EXTRA" \ | |
| 3>&1 1>&2 2>&3) || EXTRA="" | |
| echo "$EXTRA" > "$EXTRA_FILE" | |
| continue ;; | |
| "Browse...") | |
| DIR=$(browse_dir "$HOME") || continue | |
| break ;; | |
| *) | |
| DIR="$PICK" | |
| break ;; | |
| esac | |
| done | |
| # Restore original terminal colors | |
| printf '\033]4;7;#6e7781\007' | |
| printf '\033]4;15;#8c959f\007' | |
| clear | |
| CLAUDE_ARGS="" | |
| if [ "$DANGEROUS" = "on" ]; then | |
| CLAUDE_ARGS="$CLAUDE_ARGS --dangerously-skip-permissions" | |
| printf '\033]11;#fcf3cf\007' | |
| printf '\033]10;#1a1a1a\007' | |
| fi | |
| [ -n "$EXTRA" ] && CLAUDE_ARGS="$CLAUDE_ARGS $EXTRA" | |
| # Set window icon (optional: requires xseticon.py and xdotool) | |
| [ -f "$HOME/.local/bin/xseticon.py" ] && python3 "$HOME/.local/bin/xseticon.py" "$(xdotool getactivewindow)" "$ICON" 2>/dev/null | |
| cd "$DIR" && claude $CLAUDE_ARGS | |
| echo "" | |
| echo " [Claude exited. Press Enter to close, or scroll up to review output]" | |
| read |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment