Skip to content

Instantly share code, notes, and snippets.

@gatopeich
Last active February 21, 2026 13:44
Show Gist options
  • Select an option

  • Save gatopeich/2b4e805f44791fc817bc043eb82ecaac to your computer and use it in GitHub Desktop.

Select an option

Save gatopeich/2b4e805f44791fc817bc043eb82ecaac to your computer and use it in GitHub Desktop.
Claude Code Chooser - TUI project picker and launcher for Claude Code
#!/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