Created
March 18, 2026 11:47
-
-
Save dawehner/7f06ddba07161dc39d3a63ab7e9012a1 to your computer and use it in GitHub Desktop.
claude-thinking-viewer: Browse and extract thinking tokens from Claude Code 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
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| CLAUDE_DIR="$HOME/.claude/projects" | |
| # Colors | |
| BOLD='\033[1m' | |
| DIM='\033[2m' | |
| CYAN='\033[36m' | |
| YELLOW='\033[33m' | |
| GREEN='\033[32m' | |
| RESET='\033[0m' | |
| usage() { | |
| echo -e "${BOLD}claude-thinking-viewer${RESET} — Browse thinking tokens from Claude Code sessions" | |
| echo "" | |
| echo "Usage: $(basename "$0") [--dump <file>] [--raw]" | |
| echo "" | |
| echo "Options:" | |
| echo " --dump <file> Write thinking tokens to a file instead of paging" | |
| echo " --raw Output raw thinking text (no headers/formatting)" | |
| echo " -h, --help Show this help" | |
| } | |
| # Parse args | |
| DUMP_FILE="" | |
| RAW=false | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --dump) DUMP_FILE="$2"; shift 2 ;; | |
| --raw) RAW=true; shift ;; | |
| -h|--help) usage; exit 0 ;; | |
| *) echo "Unknown option: $1"; usage; exit 1 ;; | |
| esac | |
| done | |
| if [[ ! -d "$CLAUDE_DIR" ]]; then | |
| echo "Error: Claude Code projects directory not found at $CLAUDE_DIR" | |
| exit 1 | |
| fi | |
| # Step 1: Pick a project | |
| echo -e "${BOLD}Select a project:${RESET}" | |
| echo "" | |
| projects=() | |
| while IFS= read -r dir; do | |
| projects+=("$dir") | |
| done < <(find "$CLAUDE_DIR" -mindepth 1 -maxdepth 1 -type d | sort) | |
| # Build display names from directory names | |
| for i in "${!projects[@]}"; do | |
| dirname=$(basename "${projects[$i]}") | |
| # Convert dashes back to path separators for readability | |
| display="${dirname//-//}" | |
| # Count sessions | |
| count=$(find "${projects[$i]}" -maxdepth 1 -name '*.jsonl' 2>/dev/null | wc -l | tr -d ' ') | |
| printf " ${CYAN}%3d${RESET}) %-60s ${DIM}(%s sessions)${RESET}\n" "$((i+1))" "$display" "$count" | |
| done | |
| echo "" | |
| read -rp "Project number: " project_choice | |
| if [[ -z "$project_choice" ]] || [[ "$project_choice" -lt 1 ]] || [[ "$project_choice" -gt "${#projects[@]}" ]]; then | |
| echo "Invalid selection." | |
| exit 1 | |
| fi | |
| project_dir="${projects[$((project_choice-1))]}" | |
| # Step 2: Pick a session (show most recent first) | |
| echo "" | |
| echo -e "${BOLD}Select a session:${RESET}" | |
| echo "" | |
| sessions=() | |
| while IFS= read -r f; do | |
| sessions+=("$f") | |
| done < <(ls -t "$project_dir"/*.jsonl 2>/dev/null) | |
| if [[ ${#sessions[@]} -eq 0 ]]; then | |
| echo "No sessions found in this project." | |
| exit 1 | |
| fi | |
| # Show up to 20 most recent | |
| limit=20 | |
| show=${#sessions[@]} | |
| if [[ $show -gt $limit ]]; then | |
| show=$limit | |
| fi | |
| for i in $(seq 0 $((show-1))); do | |
| file="${sessions[$i]}" | |
| fname=$(basename "$file" .jsonl) | |
| size=$(du -h "$file" | cut -f1 | tr -d ' ') | |
| mod=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$file" 2>/dev/null || stat -c "%y" "$file" 2>/dev/null | cut -d. -f1) | |
| # Quick check: does it have thinking tokens? | |
| has_thinking=$(python3 -c " | |
| import json, sys | |
| found = False | |
| for line in open('$file'): | |
| try: | |
| obj = json.loads(line) | |
| if obj.get('type') == 'assistant' and 'message' in obj: | |
| for block in obj['message'].get('content', []): | |
| if block.get('type') == 'thinking' and block.get('thinking'): | |
| found = True | |
| break | |
| if found: break | |
| except: pass | |
| print('Y' if found else 'N') | |
| " 2>/dev/null || echo "?") | |
| # Get first user message as preview | |
| preview=$(python3 -c " | |
| import json, sys | |
| for line in open('$file'): | |
| try: | |
| obj = json.loads(line) | |
| if obj.get('type') == 'human' and 'message' in obj: | |
| for block in obj['message'].get('content', []): | |
| if isinstance(block, dict) and block.get('type') == 'text': | |
| text = block['text'].strip().replace('\n', ' ')[:60] | |
| if text and not text.startswith('<'): | |
| print(text) | |
| sys.exit(0) | |
| break | |
| except: pass | |
| print('(no preview)') | |
| " 2>/dev/null || echo "(error)") | |
| if [[ "$has_thinking" == "Y" ]]; then | |
| thinking_marker="${GREEN}[T]${RESET}" | |
| else | |
| thinking_marker="${DIM}[ ]${RESET}" | |
| fi | |
| printf " ${CYAN}%3d${RESET}) %s ${YELLOW}%-19s${RESET} %5s ${DIM}%s${RESET} %s\n" \ | |
| "$((i+1))" "$thinking_marker" "$mod" "$size" "${fname:0:8}…" "$preview" | |
| done | |
| if [[ ${#sessions[@]} -gt $limit ]]; then | |
| echo -e " ${DIM}... and $((${#sessions[@]} - limit)) older sessions${RESET}" | |
| fi | |
| echo "" | |
| echo -e " ${GREEN}[T]${RESET} = has thinking tokens" | |
| echo "" | |
| read -rp "Session number: " session_choice | |
| if [[ -z "$session_choice" ]] || [[ "$session_choice" -lt 1 ]] || [[ "$session_choice" -gt "$show" ]]; then | |
| echo "Invalid selection." | |
| exit 1 | |
| fi | |
| selected="${sessions[$((session_choice-1))]}" | |
| # Step 3: Extract and display thinking tokens | |
| echo "" | |
| echo -e "${BOLD}Extracting thinking tokens from $(basename "$selected")...${RESET}" | |
| echo "" | |
| extract_thinking() { | |
| local file="$1" | |
| local raw_mode="$2" | |
| python3 -c " | |
| import json, sys | |
| raw = '$raw_mode' == 'true' | |
| block_num = 0 | |
| for line_num, line in enumerate(open('$file'), 1): | |
| try: | |
| obj = json.loads(line) | |
| if obj.get('type') == 'assistant' and 'message' in obj: | |
| for block in obj['message'].get('content', []): | |
| if block.get('type') == 'thinking' and block.get('thinking'): | |
| block_num += 1 | |
| text = block['thinking'] | |
| if raw: | |
| print(text) | |
| print() | |
| else: | |
| print('=' * 80) | |
| print(f' THINKING BLOCK #{block_num} ({len(text)} chars)') | |
| print('=' * 80) | |
| print() | |
| print(text) | |
| print() | |
| except json.JSONDecodeError: | |
| pass | |
| if block_num == 0: | |
| print('No thinking tokens found in this session.') | |
| else: | |
| if not raw: | |
| print('=' * 80) | |
| print(f' Total: {block_num} thinking blocks') | |
| print('=' * 80) | |
| " | |
| } | |
| if [[ -n "$DUMP_FILE" ]]; then | |
| extract_thinking "$selected" "$RAW" > "$DUMP_FILE" | |
| lines=$(wc -l < "$DUMP_FILE" | tr -d ' ') | |
| size=$(du -h "$DUMP_FILE" | cut -f1 | tr -d ' ') | |
| echo -e "${GREEN}Written to ${DUMP_FILE}${RESET} (${lines} lines, ${size})" | |
| else | |
| extract_thinking "$selected" "$RAW" | ${PAGER:-less -R} | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment