Skip to content

Instantly share code, notes, and snippets.

@ginjo
Last active May 16, 2026 23:14
Show Gist options
  • Select an option

  • Save ginjo/ca71d7725f05e8652c25b93c3391651a to your computer and use it in GitHub Desktop.

Select an option

Save ginjo/ca71d7725f05e8652c25b93c3391651a to your computer and use it in GitHub Desktop.
List ClaudeCode sessions in table format.
#!/bin/bash
# Session manager for claude-code.
#
# Lists sessions with names, dates, and first-message snippets — working around
# the claude-code bug where session names are not shown in the built-in selector.
#
# Requires: python3, claude (in PATH)
# No external dependencies (fzf not required).
#
# Expects Claude data to be in the default location '~/.claude/'.
#
# Usage:
# Run this script from the directory of the project you're listing sessions for.
#
# ./sessions.sh — list sessions for the current project (newest first)
# ./sessions.sh -a — list sessions for all projects (shows project column)
# ./sessions.sh -b — show git branch column
# ./sessions.sh -c — show context size (tokens + % of 200k window)
# ./sessions.sh -o — sort oldest first (default is newest first)
# ./sessions.sh -s — interactive numbered selector to resume a session
# ./sessions.sh <int> — set max message snippet length (default $MAX_LEN)
# ./sessions.sh -s -a -b -c 120 — flags may be combined in any order
#
# Notes:
# -s presents a numbered list; enter the number to resume, or q/Enter to quit.
# When using -s with -a, only sessions belonging to the current project dir can
# be resumed. To resume a session from another project, cd there first.
SELECT=0
ALL=0
BRANCH=0
CONTEXT=0
OLDEST=0
MAX_LEN=30
usage() {
cat <<EOF
Usage: $(basename "$0") [flags] [max_msg_length]
Run this script from the directory of the project you're listing sessions for.
(no args) List sessions for the current project (newest first)
-a List sessions for all projects
-b Show git branch column
-c Show context size (tokens and % of 200k window)
-o Sort oldest first (default is newest first)
-s Interactive numbered selector to resume a session
-h Show this help and exit
<int> Set max message snippet length (default $MAX_LEN)
Flags may be combined in any order, e.g.: -s -a -b -c 120
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
-s) SELECT=1; shift ;;
-a) ALL=1; shift ;;
-b) BRANCH=1; shift ;;
-c) CONTEXT=1; shift ;;
-o) OLDEST=1; shift ;;
-h) usage; exit 0 ;;
''|*[!0-9]*) echo "Unknown argument: $1" >&2; exit 1 ;;
*) MAX_LEN="${1:-MAX_LEN}"; shift ;;
esac
done
# Convert current working dir to the project key used by claude: /foo/bar -> -foo-bar
CURRENT_PROJECT="$(pwd | tr '/' '-')"
list_sessions() {
python3 - "$1" "$2" "$3" "$4" "$5" <<'PYEOF'
import os, json, glob, re, sys
snippet_len = int(sys.argv[1]) if len(sys.argv) > 1 and sys.argv[1] else 0
project_filter = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else None
show_branch = sys.argv[3] == "1" if len(sys.argv) > 3 else False
show_context = sys.argv[4] == "1" if len(sys.argv) > 4 else False
oldest_first = sys.argv[5] == "1" if len(sys.argv) > 5 else False
CONTEXT_WINDOW = 200_000
def extract_text(content):
if isinstance(content, str):
text = content.strip()
if text.startswith("<"):
return ""
return text.replace("\n", " ")[:snippet_len]
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "").strip()
if text and not text.startswith("<"):
return text.replace("\n", " ")[:snippet_len]
return ""
sessions = {}
for path in glob.glob(os.path.expanduser("~/.claude/projects/**/*.jsonl"), recursive=True):
if "/subagents/" in path:
continue
# Extract project dir name (the component after ~/.claude/projects/)
parts = path.split(os.sep)
try:
proj_idx = parts.index("projects") + 1
project = parts[proj_idx]
except (ValueError, IndexError):
project = ""
if project_filter and project != project_filter:
continue
with open(path) as f:
for line in f:
try:
obj = json.loads(line)
sid = obj.get("sessionId")
if not sid:
continue
ts = obj.get("timestamp", "")
if sid not in sessions:
sessions[sid] = {"ts": ts, "project": project, "name": "", "rename_ts": "", "first_msg": "", "asst_snippet": "", "branch": obj.get("gitBranch", ""), "max_input_tokens": 0, "max_cached_tokens": 0}
elif not sessions[sid]["branch"] and obj.get("gitBranch"):
sessions[sid]["branch"] = obj["gitBranch"]
elif ts and (not sessions[sid]["ts"] or ts < sessions[sid]["ts"]):
sessions[sid]["ts"] = ts
if obj.get("type") == "assistant":
usage = obj.get("message", {}).get("usage", {})
if isinstance(usage, dict):
cached = usage.get("cache_read_input_tokens", 0)
total_input = (usage.get("input_tokens", 0) +
usage.get("cache_creation_input_tokens", 0) +
cached)
if total_input > sessions[sid]["max_input_tokens"]:
sessions[sid]["max_input_tokens"] = total_input
sessions[sid]["max_cached_tokens"] = cached
content = obj.get("message", {}).get("content", "")
rename_content = None
if isinstance(content, str) and "/rename" in content:
rename_content = content
elif obj.get("type") == "system" and obj.get("subtype") == "local_command":
c = obj.get("content", "")
if isinstance(c, str) and "/rename" in c:
rename_content = c
if rename_content:
m = re.search(r'<command-args>(.*?)</command-args>', rename_content)
if m:
name = m.group(1).strip()
if not sessions[sid]["name"] or ts > sessions[sid]["rename_ts"]:
sessions[sid]["name"] = name
sessions[sid]["rename_ts"] = ts
if not sessions[sid]["first_msg"]:
if obj.get("type") == "user" and obj.get("userType") == "external":
text = extract_text(content)
if text:
sessions[sid]["first_msg"] = text
if not sessions[sid]["asst_snippet"] and obj.get("type") == "assistant":
text = extract_text(obj.get("message", {}).get("content", ""))
if text:
sessions[sid]["asst_snippet"] = f"[asst] {text}"
except:
pass
for sid, s in sorted(sessions.items(), key=lambda x: x[1]["ts"], reverse=not oldest_first):
snippet = s["first_msg"] or s["asst_snippet"]
branch_col = f" {s['branch']:20s}" if show_branch else ""
if show_context:
tok = s["max_input_tokens"]
cached = s["max_cached_tokens"]
pct = tok / CONTEXT_WINDOW * 100 if tok else 0
#ctx_col = f" {cached/1000:>5.1f}k cached {tok/1000:>5.1f}k ({pct:4.1f}%)"
ctx_col = f" {tok/1000:>5.1f}k ({pct:4.1f}%) {cached/1000:>5.1f}k cached"
else:
ctx_col = ""
if project_filter is None:
proj = s["project"].lstrip("-")
print(f"{s['ts'][:10]} {proj:30s} {s['name']:35s}{branch_col}{ctx_col} {sid} {snippet}")
else:
print(f"{s['ts'][:10]} {s['name']:40s}{branch_col}{ctx_col} {sid} {snippet}")
PYEOF
}
# Pass project filter unless -a was given
FILTER=""
[[ $ALL -eq 0 ]] && FILTER="$CURRENT_PROJECT"
if [[ $SELECT -eq 1 ]]; then
# Build the session list and present a numbered menu
mapfile -t LINES < <(list_sessions "$MAX_LEN" "$FILTER" "$BRANCH" "$CONTEXT" "$OLDEST")
if [[ ${#LINES[@]} -eq 0 ]]; then
echo "No sessions found." >&2
exit 0
fi
for i in "${!LINES[@]}"; do
printf "%3d %s\n" "$((i+1))" "${LINES[$i]}"
done
echo ""
read -r -p "Select session number to resume (or q to quit): " CHOICE
if [[ "$CHOICE" == "q" || "$CHOICE" == "Q" || -z "$CHOICE" ]]; then
exit 0
fi
if ! [[ "$CHOICE" =~ ^[0-9]+$ ]] || (( CHOICE < 1 || CHOICE > ${#LINES[@]} )); then
echo "Invalid selection." >&2
exit 1
fi
SELECTED_LINE="${LINES[$((CHOICE-1))]}"
# When -a is active, the project column is shown — block cross-project resumption
if [[ $ALL -eq 1 ]]; then
SELECTED_PROJECT=$(echo "$SELECTED_LINE" | awk '{print $2}')
CURRENT_PROJECT_DISPLAY="${CURRENT_PROJECT#-}"
if [[ "$SELECTED_PROJECT" != "$CURRENT_PROJECT_DISPLAY" ]]; then
echo "That session belongs to project '$SELECTED_PROJECT'." >&2
echo "Please cd to that project directory first, then run this script again." >&2
exit 1
fi
fi
SID=$(echo "$SELECTED_LINE" | grep -Eo '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
if [[ -z "$SID" ]]; then
echo "Could not extract session ID." >&2
exit 1
fi
claude --resume "$SID"
else
list_sessions "$MAX_LEN" "$FILTER" "$BRANCH" "$CONTEXT" "$OLDEST"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment