diff --git a/README.md b/README.md index cbb4ef9..b731102 100644 --- a/README.md +++ b/README.md @@ -4,25 +4,65 @@ Browse and export Claude Code chat history — Web GUI and CLI. ## Features -- **Web GUI**: Flask-based browser with project list, session viewer, full-text search, syntax highlighting, tool call rendering, thinking blocks, dark/light mode -- **CLI Export**: Standalone script to export all sessions to Markdown with YAML frontmatter -- **Rich Markdown**: Includes token usage, tool calls (Bash, Read, Edit, Write, Glob, Grep, Task, etc.), thinking blocks, model info, timestamps -- **Incremental Export**: `--since last` flag to only export new/updated sessions -- **Bulk Export**: Download all sessions as a zip from the web UI +### Web GUI +- **Project dashboard** with card grid, aggregate stats, and staggered animations +- **Session viewer** with split layout (sidebar + message panel) +- **Full-text search** across all sessions +- **Syntax highlighting** for code blocks +- **Tool call rendering** — Bash, Read, Edit, Write, Glob, Grep, Task, TodoWrite, WebFetch, WebSearch, and more +- **Thinking blocks** — collapsible sections for Claude's reasoning +- **Dark/light theme** with Inter font +- **Responsive design** — mobile-friendly with hamburger sidebar +- **Toast notifications** with icon, progress bar, and close button +- **Confirm modals** with keyboard support (Enter/Escape) and backdrop blur +- **Top loading bar** (YouTube-style) during data fetches +- **Smooth transitions** — staggered card/message animations, crossfade content swaps +- **Scroll-to-top button** in bottom-right corner +- **Per-model badges** in session header +- **Bulk export** — download all sessions as a zip + +### CLI Export +- Standalone script to export all sessions to Markdown with YAML frontmatter +- Rich Markdown: token usage, tool calls, thinking blocks, model info, timestamps +- `--since last` flag for incremental export (only new/updated sessions) +- `--project` flag to export a specific project ## Quick Start ### Web GUI ```bash -pip install flask +# Create virtual environment +python -m venv venv + +# Activate (Windows) +venv\Scripts\activate + +# Activate (macOS/Linux) +source venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Run python app.py -# Open http://localhost:3000 +# Open http://localhost:5000 +``` + +Options: +```bash +python app.py --port 8080 --host 0.0.0.0 +python app.py --base-dir /path/to/claude/projects ``` ### CLI Export ```bash +# Activate venv first (see above), then: + +# List all projects (shows directory names you can use with --project) +python scripts/export.py list + # Export all sessions as zip python scripts/export.py @@ -32,10 +72,12 @@ python scripts/export.py --out ./exports --no-zip # Incremental export (only new sessions since last run) python scripts/export.py --since last -# Export specific project only -python scripts/export.py --project myproject +# Export specific project only (substring match on directory name) +python scripts/export.py --project boost-capy ``` +The `--project` flag matches against the directory names under `~/.claude/projects/`. These are path-based names like `F--boost-capy` or `d--harbor-forge`. You can use any substring — for example `boost-capy` will match `F--boost-capy`. Run `python scripts/export.py list` to see all available project names. + ## Data Source Reads from `~/.claude/projects/` which contains JSONL session files created by Claude Code. @@ -46,22 +88,22 @@ Reads from `~/.claude/projects/` which contains JSONL session files created by C ``` claude-code-chat-browser/ -├── app.py # Flask entry point +├── app.py # Flask entry point (default port 5000) ├── api/ -│ ├── projects.py # Project listing endpoints -│ ├── sessions.py # Session viewer endpoints -│ ├── search.py # Full-text search -│ └── export_api.py # Bulk and per-session export +│ ├── projects.py # Project listing & session counts +│ ├── sessions.py # Session parsing & message delivery +│ ├── search.py # Full-text search across sessions +│ └── export_api.py # Bulk zip and per-session Markdown export ├── utils/ -│ ├── session_path.py # OS-aware path detection -│ ├── jsonl_parser.py # JSONL session parser -│ └── md_exporter.py # Markdown exporter with frontmatter +│ ├── session_path.py # OS-aware path detection & project naming +│ ├── jsonl_parser.py # JSONL session parser with tool result classification +│ └── md_exporter.py # Markdown exporter with YAML frontmatter ├── scripts/ -│ └── export.py # Standalone CLI export +│ └── export.py # Standalone CLI export tool ├── static/ -│ ├── index.html # SPA entry point -│ ├── css/style.css # Dark/light theme -│ └── js/app.js # Client-side routing and rendering +│ ├── index.html # SPA entry point (Inter font, minimal markup) +│ ├── css/style.css # Dark/light theme, responsive, animations +│ └── js/app.js # Hash-based routing, rendering, UI components └── tests/ ``` @@ -72,5 +114,5 @@ Each exported session includes: - **YAML frontmatter**: title, timestamps, session_id, models, token counts, tool call breakdown, working directory, git branch, Claude Code version - **Per-message metadata**: role, model, token usage (in/out/cache), timestamp - **Thinking blocks**: Collapsible `
` sections -- **Tool calls**: Formatted by type (Bash commands, file reads/edits, glob/grep patterns, subagent tasks, todos) +- **Tool calls**: Formatted by type (Bash commands, file reads/edits, glob/grep patterns, subagent tasks, todos, web fetches, plans) - **System events**: Context compaction markers diff --git a/api/export_api.py b/api/export_api.py index 5dc3ade..0f4b20f 100644 --- a/api/export_api.py +++ b/api/export_api.py @@ -1,14 +1,17 @@ -"""API endpoint for bulk export.""" +"""Export endpoints -- bulk zip download and single-session md/json.""" import io +import json import zipfile from datetime import datetime -from flask import Blueprint, current_app, jsonify, send_file +from flask import Blueprint, current_app, jsonify, request, send_file from utils.session_path import get_claude_projects_dir, list_projects, list_sessions from utils.jsonl_parser import parse_session +from utils.session_stats import compute_stats from utils.md_exporter import session_to_markdown +from utils.json_exporter import session_to_json export_bp = Blueprint("export", __name__) @@ -20,6 +23,7 @@ def bulk_export(): buf = io.BytesIO() count = 0 + manifest = [] with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for project in projects: sessions = list_sessions(project["path"]) @@ -28,15 +32,29 @@ def bulk_export(): session = parse_session(sess_info["path"]) if session["title"] == "Untitled Session": continue - md = session_to_markdown(session) - title_slug = _slugify(session["title"])[:60] + stats = compute_stats(session) + md = session_to_markdown(session, stats) + title_slug = _slugify(session["title"])[:60] or "session" short_id = sess_info["id"][:8] proj_slug = _slugify(project["name"]) rel_path = f"{proj_slug}/{title_slug}__{short_id}.md" zf.writestr(rel_path, md) + manifest.append({ + "session_id": sess_info["id"], + "title": session["title"], + "project": project["name"], + "tokens": session["metadata"]["total_input_tokens"] + + session["metadata"]["total_output_tokens"], + "tool_calls": session["metadata"]["total_tool_calls"], + "cost_estimate_usd": stats.get("cost_estimate_usd"), + }) count += 1 - except Exception: + except Exception as e: + current_app.logger.warning("Failed to export %s: %s", sess_info["id"][:10], e) continue + if manifest: + manifest_str = "\n".join(json.dumps(e, default=str) for e in manifest) + zf.writestr("manifest.jsonl", manifest_str) buf.seek(0) date_tag = datetime.now().strftime("%Y-%m-%d") @@ -49,20 +67,37 @@ def bulk_export(): @export_bp.route("/api/export/session//") -def export_session_md(project_name, session_id): +def export_session(project_name, session_id): import os + from utils.session_path import safe_join base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir() - filepath = os.path.join(base, project_name, f"{session_id}.jsonl") + try: + filepath = safe_join(base, project_name, f"{session_id}.jsonl") + except ValueError: + return jsonify({"error": "Invalid path"}), 400 if not os.path.isfile(filepath): return jsonify({"error": "Session not found"}), 404 + fmt = request.args.get("format", "md") session = parse_session(filepath) - md = session_to_markdown(session) + stats = compute_stats(session) + title_slug = _slugify(session["title"])[:60] or "session" + if fmt == "json": + content = session_to_json(session, stats) + buf = io.BytesIO(content.encode("utf-8")) + buf.seek(0) + return send_file( + buf, + mimetype="application/json", + as_attachment=True, + download_name=f"{title_slug}.json", + ) + + md = session_to_markdown(session, stats) buf = io.BytesIO(md.encode("utf-8")) buf.seek(0) - title_slug = _slugify(session["title"])[:60] return send_file( buf, mimetype="text/markdown", diff --git a/api/projects.py b/api/projects.py index f36fe25..84bc1ff 100644 --- a/api/projects.py +++ b/api/projects.py @@ -1,8 +1,10 @@ -"""API endpoints for listing projects.""" +"""Project listing endpoints.""" + +import traceback from flask import Blueprint, current_app, jsonify -from utils.session_path import get_claude_projects_dir, list_projects, list_sessions +from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join projects_bp = Blueprint("projects", __name__) @@ -11,14 +13,40 @@ def get_projects(): base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir() projects = list_projects(base) + + # Enrich each project with accurate titled-session count and latest timestamp + # so the landing page matches what the workspace page shows. + # Uses quick_session_info() which peeks at files without full parsing. + from utils.jsonl_parser import quick_session_info + for project in projects: + sessions = list_sessions(project["path"]) + titled_count = 0 + latest_ts = None + for s in sessions: + try: + info = quick_session_info(s["path"]) + if info["title"] == "Untitled Session": + continue + titled_count += 1 + ts = info.get("last_timestamp") or info.get("first_timestamp") + if ts and (latest_ts is None or ts > latest_ts): + latest_ts = ts + except Exception: + titled_count += 1 + project["session_count"] = titled_count + if latest_ts: + project["last_modified"] = latest_ts + return jsonify(projects) @projects_bp.route("/api/projects//sessions") def get_project_sessions(project_name): base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir() - import os - project_dir = os.path.join(base, project_name) + try: + project_dir = safe_join(base, project_name) + except ValueError: + return jsonify([]), 400 sessions = list_sessions(project_dir) # Add summary preview for each session from utils.jsonl_parser import parse_session @@ -39,6 +67,7 @@ def get_project_sessions(project_name): "first_timestamp": meta["first_timestamp"], "last_timestamp": meta["last_timestamp"], }) - except Exception: - result.append({**s, "title": "Error parsing session", "error": True}) + except Exception as e: + print(f"[ERROR] Failed to parse {s['id']}: {type(e).__name__}: {e}\n{traceback.format_exc()}") + result.append({**s, "title": "Error parsing session", "error": True, "error_detail": f"{type(e).__name__}: {e}"}) return jsonify(result) diff --git a/api/search.py b/api/search.py index 70f9758..48f741c 100644 --- a/api/search.py +++ b/api/search.py @@ -1,4 +1,4 @@ -"""API endpoint for full-text search across all sessions.""" +"""Search endpoint. Brute-force substring match across all sessions.""" import os diff --git a/api/sessions.py b/api/sessions.py index f547f20..90f8700 100644 --- a/api/sessions.py +++ b/api/sessions.py @@ -1,11 +1,13 @@ -"""API endpoints for viewing individual sessions.""" +"""Session detail and stats endpoints.""" import os +import traceback from flask import Blueprint, current_app, jsonify, abort -from utils.session_path import get_claude_projects_dir +from utils.session_path import get_claude_projects_dir, safe_join from utils.jsonl_parser import parse_session +from utils.session_stats import compute_stats sessions_bp = Blueprint("sessions", __name__) @@ -13,10 +15,43 @@ @sessions_bp.route("/api/sessions//") def get_session(project_name, session_id): base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir() - filepath = os.path.join(base, project_name, f"{session_id}.jsonl") + try: + filepath = safe_join(base, project_name, f"{session_id}.jsonl") + except ValueError: + return jsonify({"error": "Invalid path"}), 400 if not os.path.isfile(filepath): - abort(404, description=f"Session {session_id} not found") + return jsonify({"error": f"Session {session_id} not found"}), 404 - session = parse_session(filepath) - return jsonify(session) + try: + session = parse_session(filepath) + return jsonify(session) + except Exception as e: + tb = traceback.format_exc() + print(f"[ERROR] Failed to parse session {session_id}: {e}\n{tb}") + return jsonify({ + "error": f"Failed to parse session: {type(e).__name__}: {e}", + }), 500 + + +@sessions_bp.route("/api/sessions///stats") +def get_session_stats(project_name, session_id): + base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir() + try: + filepath = safe_join(base, project_name, f"{session_id}.jsonl") + except ValueError: + return jsonify({"error": "Invalid path"}), 400 + + if not os.path.isfile(filepath): + return jsonify({"error": f"Session {session_id} not found"}), 404 + + try: + session = parse_session(filepath) + stats = compute_stats(session) + return jsonify(stats) + except Exception as e: + tb = traceback.format_exc() + print(f"[ERROR] Failed to compute stats for {session_id}: {e}\n{tb}") + return jsonify({ + "error": f"Failed to compute stats: {type(e).__name__}: {e}", + }), 500 diff --git a/app.py b/app.py index d947a11..8516839 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -"""Flask web application for browsing Claude Code chat history.""" +"""Flask app that serves the web GUI for browsing sessions.""" import os @@ -30,11 +30,11 @@ def index(): import argparse parser = argparse.ArgumentParser(description="Claude Code Chat Browser") - parser.add_argument("--port", type=int, default=3000) + parser.add_argument("--port", type=int, default=5000) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--base-dir", default=None, help="Override Claude projects dir") args = parser.parse_args() app = create_app(base_dir=args.base_dir) print(f"Claude Code Chat Browser running at http://{args.host}:{args.port}") - app.run(host=args.host, port=args.port, debug=True) + app.run(host=args.host, port=args.port, debug=True, use_reloader=False) diff --git a/scripts/export.py b/scripts/export.py index 557de4a..378a00f 100644 --- a/scripts/export.py +++ b/scripts/export.py @@ -1,14 +1,17 @@ #!/usr/bin/env python3 -"""CLI tool to export Claude Code chat history to Markdown files. - -Usage: - python scripts/export.py # export all sessions as zip - python scripts/export.py --since last # incremental export - python scripts/export.py --out ./exports # custom output directory - python scripts/export.py --no-zip # individual MD files, no zip - python scripts/export.py --project myproject # export specific project only +"""CLI for exporting Claude Code chat history. + +Examples: + export.py # zip of all sessions as markdown + export.py list # show projects + export.py list --project foo # show sessions in a project + export.py stats # token/cost totals + export.py stats --session UUID # single session breakdown + export.py --format json --no-zip # JSON files instead of zip + export.py --since last # only sessions changed since last run """ +import argparse import json import os import sys @@ -22,7 +25,9 @@ from utils.session_path import get_claude_projects_dir, list_projects, list_sessions from utils.jsonl_parser import parse_session +from utils.session_stats import compute_stats, _format_duration from utils.md_exporter import session_to_markdown +from utils.json_exporter import session_to_json STATE_DIR = os.path.join(os.path.expanduser("~"), ".claude-code-chat-browser") @@ -30,32 +35,283 @@ def main(): - args = parse_args(sys.argv[1:]) + parser = build_parser() + args = parser.parse_args() - base_dir = args.get("base_dir") or get_claude_projects_dir() - out_dir = args.get("out") or os.getcwd() - since = args.get("since", "all") - no_zip = args.get("no_zip", False) - project_filter = args.get("project") + command = getattr(args, "command", None) or "export" + + if command == "list": + cmd_list(args) + elif command == "stats": + cmd_stats(args) + else: + cmd_export(args) + +def cmd_list(args): + """Print a table of projects, or drill into one project's sessions.""" + base_dir = getattr(args, "base_dir", None) or get_claude_projects_dir() + project_filter = getattr(args, "project", None) + + if not os.path.isdir(base_dir): + _die(f"Claude Code projects directory not found: {base_dir}") + + projects = list_projects(base_dir) + if project_filter: + projects = [p for p in projects if project_filter in p["name"]] + + if not projects: + print("No projects found.") + return + + # If a specific project is selected, list its sessions + if project_filter and len(projects) == 1: + _list_sessions(projects[0]) + return + + # Otherwise list all projects + print(f"Projects ({len(projects)} found):\n") + print(f" {'Project':<45} {'Sessions':>8} {'Last Modified'}") + print(f" {chr(9472) * 45} {chr(9472) * 8} {chr(9472) * 19}") + for p in sorted(projects, key=lambda x: x.get("last_modified", ""), reverse=True): + name = p.get("display_name") or p["name"] + count = p.get("session_count", 0) + modified = p.get("last_modified", "")[:19].replace("T", " ") + print(f" {name:<45} {count:>8} {modified}") + + +def _list_sessions(project: dict): + """Print each session in a project with title, tokens, tool count.""" + sessions = list_sessions(project["path"]) + name = project.get("display_name") or project["name"] + print(f"Sessions in {name} ({len(sessions)} found):\n") + print(f" {'Date':<12} {'Title':<50} {'ID':>10} {'Tokens':>10} {'Tools':>6}") + print(f" {chr(9472) * 12} {chr(9472) * 50} {chr(9472) * 10} {chr(9472) * 10} {chr(9472) * 6}") + + for s in sorted(sessions, key=lambda x: x.get("modified", 0), reverse=True): + try: + parsed = parse_session(s["path"]) + if parsed["title"] == "Untitled Session": + continue + meta = parsed["metadata"] + ts = (meta.get("first_timestamp") or "")[:10] + title = parsed["title"][:50] + sid = s["id"][:10] + tokens = meta["total_input_tokens"] + meta["total_output_tokens"] + tools = meta["total_tool_calls"] + print(f" {ts:<12} {title:<50} {sid:>10} {tokens:>10,} {tools:>6}") + except Exception as e: + print(f" Warning: failed to parse {s['id'][:10]}: {e}", file=sys.stderr) + continue + + +def cmd_stats(args): + """Show stats -- either for one session or aggregated across all.""" + base_dir = getattr(args, "base_dir", None) or get_claude_projects_dir() + project_filter = getattr(args, "project", None) + session_id = getattr(args, "session", None) + fmt = getattr(args, "format", "text") or "text" if not os.path.isdir(base_dir): - print(f"Error: Claude Code projects directory not found: {base_dir}") - print("Make sure Claude Code has been used on this machine.") - sys.exit(1) + _die(f"Claude Code projects directory not found: {base_dir}") + + if session_id: + _session_stats(session_id, base_dir, fmt) + else: + _aggregate_stats(base_dir, project_filter, fmt) + + +def _session_stats(session_id: str, base_dir: str, fmt: str): + """Detailed breakdown for one session: tokens, files, commands, cost.""" + filepath = _find_session(session_id, base_dir) + if not filepath: + _die(f"Session not found: {session_id}") + + session = parse_session(filepath) + stats = compute_stats(session) + + if fmt == "json": + print(json.dumps(stats, indent=2, default=str)) + return + + meta = session["metadata"] + print(f"=== Session: {session_id[:12]} ===\n") + print(f" Title: {session['title']}") + if meta["first_timestamp"]: + print(f" Created: {meta['first_timestamp'][:19]}") + dur = _format_duration(meta.get("session_wall_time_seconds")) + if dur: + print(f" Duration: {dur}") + print(f" Models: {', '.join(meta['models_used']) or 'unknown'}") + inp = meta["total_input_tokens"] + out = meta["total_output_tokens"] + print(f" Tokens: {inp + out:,} (input: {inp:,} / output: {out:,})") + cache_r = meta["total_cache_read_tokens"] + cache_c = meta["total_cache_creation_tokens"] + if cache_r or cache_c: + print(f" Cache: read: {cache_r:,} / creation: {cache_c:,}") + print(f" Tool calls: {meta['total_tool_calls']}") + if meta["tool_call_counts"]: + breakdown = ", ".join( + f"{t}: {c}" for t, c in sorted( + meta["tool_call_counts"].items(), key=lambda x: -x[1] + ) + ) + print(f" {breakdown}") + if meta.get("stop_reasons"): + sr = ", ".join(f"{r}: {c}" for r, c in meta["stop_reasons"].items()) + print(f" Stop: {sr}") + + ft = stats.get("files_touched", {}) + total_files = ft.get("total_unique", 0) + if total_files: + print( + f" Files: {total_files} unique " + f"({len(ft.get('read', []))} read, " + f"{len(ft.get('written', []))} edited, " + f"{len(ft.get('created', []))} created)" + ) + cmds = stats.get("commands_run", []) + if cmds: + trs = stats.get("tool_result_summary", {}) + ok = trs.get("bash_success", 0) + err = trs.get("bash_error", 0) + print(f" Commands: {len(cmds)} run ({ok} success, {err} error)") + if meta.get("compactions"): + print(f" Compactions: {meta['compactions']}") + if meta.get("api_errors"): + print(f" API errors: {meta['api_errors']}") + cost = stats.get("cost_estimate_usd") + if cost is not None: + print(f" Est. cost: ~${cost:.2f} USD") + + +def _aggregate_stats(base_dir: str, project_filter: str, fmt: str): + """Sum up tokens, tools, cost across every session. Optionally filter + by project.""" + projects = list_projects(base_dir) + if project_filter: + projects = [p for p in projects if project_filter in p["name"]] + + totals = { + "projects": len(projects), + "sessions": 0, + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "tool_calls": 0, + "tool_counts": {}, + "models": set(), + "files_unique": set(), + "commands_run": 0, + "compactions": 0, + "api_errors": 0, + "total_cost": 0.0, + "has_cost": False, + } + + for project in projects: + sessions = list_sessions(project["path"]) + for s in sessions: + try: + session = parse_session(s["path"]) + if session["title"] == "Untitled Session": + continue + meta = session["metadata"] + stats = compute_stats(session) + + totals["sessions"] += 1 + totals["input_tokens"] += meta["total_input_tokens"] + totals["output_tokens"] += meta["total_output_tokens"] + totals["cache_read_tokens"] += meta["total_cache_read_tokens"] + totals["cache_creation_tokens"] += meta["total_cache_creation_tokens"] + totals["tool_calls"] += meta["total_tool_calls"] + for t, c in meta["tool_call_counts"].items(): + totals["tool_counts"][t] = totals["tool_counts"].get(t, 0) + c + totals["models"].update(meta["models_used"]) + ft = stats.get("files_touched", {}) + for category in ("read", "written", "created"): + totals["files_unique"].update(ft.get(category, [])) + totals["commands_run"] += len(stats.get("commands_run", [])) + totals["compactions"] += meta.get("compactions", 0) + totals["api_errors"] += meta.get("api_errors", 0) + cost = stats.get("cost_estimate_usd") + if cost is not None: + totals["total_cost"] += cost + totals["has_cost"] = True + except Exception as e: + print(f" Warning: failed to parse {s['id'][:10]} in {project['name']}: {e}", file=sys.stderr) + continue + + if fmt == "json": + out = dict(totals) + out["models"] = sorted(out["models"]) + out["files_unique"] = len(out["files_unique"]) + print(json.dumps(out, indent=2, default=str)) + return + + total_tokens = totals["input_tokens"] + totals["output_tokens"] + print("=== Aggregate Stats ===\n") + print(f" Projects: {totals['projects']}") + print(f" Sessions: {totals['sessions']}") + print(f" Models: {', '.join(sorted(totals['models'])) or 'none'}") + print(f" Total tokens: {total_tokens:,} (input: {totals['input_tokens']:,} / output: {totals['output_tokens']:,})") + if totals["cache_read_tokens"]: + print(f" Cache: read: {totals['cache_read_tokens']:,} / creation: {totals['cache_creation_tokens']:,}") + print(f" Tool calls: {totals['tool_calls']:,}") + if totals["tool_counts"]: + breakdown = ", ".join( + f"{t}: {c}" for t, c in sorted( + totals["tool_counts"].items(), key=lambda x: -x[1] + )[:10] + ) + print(f" {breakdown}") + print(f" Files: {len(totals['files_unique']):,} unique") + print(f" Commands: {totals['commands_run']:,}") + if totals["compactions"]: + print(f" Compactions: {totals['compactions']}") + if totals["api_errors"]: + print(f" API errors: {totals['api_errors']}") + if totals["has_cost"]: + print(f" Est. cost: ~${totals['total_cost']:.2f} USD") + + +def cmd_export(args): + """The main export command. Writes md/json files, optionally zipped.""" + base_dir = getattr(args, "base_dir", None) or get_claude_projects_dir() + out_dir = getattr(args, "out", None) or os.getcwd() + since = getattr(args, "since", None) or "all" + no_zip = getattr(args, "no_zip", False) + project_filter = getattr(args, "project", None) + fmt = getattr(args, "format", None) or "md" + session_filter = getattr(args, "session", None) + + if not os.path.isdir(base_dir): + _die(f"Claude Code projects directory not found: {base_dir}") last_export = _load_state() if since == "last" else {} + # Single session export + if session_filter: + filepath = _find_session(session_filter, base_dir) + if not filepath: + _die(f"Session not found: {session_filter}") + session = parse_session(filepath) + stats = compute_stats(session) + _export_single(session, stats, fmt, out_dir) + return + projects = list_projects(base_dir) if project_filter: projects = [p for p in projects if project_filter in p["name"]] if not projects: print("No projects found.") - sys.exit(0) + return print(f"Found {len(projects)} project(s) in {base_dir}") - all_exports = [] + all_exports = [] # list of (rel_path, content) manifest = [] total_sessions = 0 skipped = 0 @@ -78,39 +334,61 @@ def main(): print(f" Warning: failed to parse {sid}: {e}") continue - md = session_to_markdown(session) + if session["title"] == "Untitled Session": + skipped += 1 + continue + + stats = compute_stats(session) meta = session["metadata"] ts = meta.get("first_timestamp", "") if not ts: - # Fallback: use file modification time from datetime import datetime as _dt - ts = _dt.fromtimestamp(sess_info["modified"]).strftime("%Y-%m-%dT%H:%M:%S") + ts = _dt.fromtimestamp(sess_info["modified"]).strftime( + "%Y-%m-%dT%H:%M:%S" + ) meta["first_timestamp"] = ts date_str = ts[:10] title_slug = _slugify(session["title"])[:60] short_id = sid[:8] project_slug = _slugify(project["name"]) - rel_path = os.path.join( - date_str, project_slug, f"{title_slug}__{short_id}.md" - ) + if fmt in ("md", "both"): + md = session_to_markdown(session, stats) + rel_path = os.path.join( + date_str, project_slug, f"{title_slug}__{short_id}.md" + ) + all_exports.append((rel_path, md)) + + if fmt in ("json", "both"): + js = session_to_json(session, stats) + rel_path = os.path.join( + date_str, project_slug, f"{title_slug}__{short_id}.json" + ) + all_exports.append((rel_path, js)) - all_exports.append((rel_path, md)) manifest.append({ "session_id": sid, - "path": rel_path, "title": session["title"], "project": project["name"], "updated_at": meta.get("last_timestamp", ""), "models": meta.get("models_used", []), "tokens": meta["total_input_tokens"] + meta["total_output_tokens"], "tool_calls": meta["total_tool_calls"], + "files_touched": stats.get("files_touched", {}).get( + "total_unique", 0 + ), + "commands_run": len(stats.get("commands_run", [])), + "cost_estimate_usd": stats.get("cost_estimate_usd"), + "wall_clock_seconds": meta.get("session_wall_time_seconds"), }) last_export[sid] = sess_info["modified"] exported = len(all_exports) - print(f"Exporting {exported} session(s) ({skipped} skipped, {total_sessions} total)") + print( + f"Exporting {exported} file(s) " + f"({skipped} skipped, {total_sessions} total)" + ) if not all_exports: print("Nothing to export.") @@ -119,59 +397,140 @@ def main(): os.makedirs(out_dir, exist_ok=True) if no_zip: - for rel_path, md in all_exports: + for rel_path, content in all_exports: full_path = os.path.join(out_dir, rel_path) os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, "w", encoding="utf-8") as f: - f.write(md) + f.write(content) manifest_path = os.path.join(out_dir, "manifest.jsonl") with open(manifest_path, "w", encoding="utf-8") as f: for entry in manifest: - f.write(json.dumps(entry) + "\n") + f.write(json.dumps(entry, default=str) + "\n") print(f"Exported {exported} file(s) to {out_dir}") else: date_tag = datetime.now().strftime("%Y-%m-%d") zip_name = f"claude-code-export-{date_tag}.zip" zip_path = os.path.join(out_dir, zip_name) with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - for rel_path, md in all_exports: - zf.writestr(rel_path, md) - manifest_str = "\n".join(json.dumps(e) for e in manifest) + for rel_path, content in all_exports: + zf.writestr(rel_path, content) + manifest_str = "\n".join( + json.dumps(e, default=str) for e in manifest + ) zf.writestr("manifest.jsonl", manifest_str) - print(f"Exported {exported} session(s) to {zip_path}") + print(f"Exported {exported} file(s) to {zip_path}") _save_state(last_export) print("Export state saved.") -def parse_args(argv: list) -> dict: - args = {} - i = 0 - while i < len(argv): - arg = argv[i] - if arg == "--since" and i + 1 < len(argv): - args["since"] = argv[i + 1] - i += 2 - elif arg == "--out" and i + 1 < len(argv): - args["out"] = argv[i + 1] - i += 2 - elif arg == "--project" and i + 1 < len(argv): - args["project"] = argv[i + 1] - i += 2 - elif arg == "--base-dir" and i + 1 < len(argv): - args["base_dir"] = argv[i + 1] - i += 2 - elif arg == "--no-zip": - args["no_zip"] = True - i += 1 - elif arg in ("--help", "-h"): - print(__doc__) - sys.exit(0) - else: - print(f"Unknown argument: {arg}") - print(__doc__) - sys.exit(1) - return args +def _export_single(session: dict, stats: dict, fmt: str, out_dir: str): + """Write one session to disk as md, json, or both.""" + title_slug = _slugify(session["title"])[:60] + short_id = session["session_id"][:8] + + files = [] + if fmt in ("md", "both"): + md = session_to_markdown(session, stats) + files.append((f"{title_slug}__{short_id}.md", md)) + if fmt in ("json", "both"): + js = session_to_json(session, stats) + files.append((f"{title_slug}__{short_id}.json", js)) + + os.makedirs(out_dir, exist_ok=True) + for fname, content in files: + fpath = os.path.join(out_dir, fname) + with open(fpath, "w", encoding="utf-8") as f: + f.write(content) + print(f"Exported: {fpath}") + + +# ==================== Argument Parser ==================== + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Export Claude Code chat history to Markdown/JSON", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + # Global options (for backward compatibility when no subcommand) + parser.add_argument("--base-dir", default=None, + help="Override Claude Code projects directory") + parser.add_argument("--project", default=None, + help="Filter by project name (substring match)") + parser.add_argument("--since", choices=["all", "last"], default=None, + help="Export all or only new since last run") + parser.add_argument("--out", default=None, + help="Output directory (default: current dir)") + parser.add_argument("--no-zip", action="store_true", default=False, + help="Write individual files instead of zip") + parser.add_argument("--format", choices=["md", "json", "both"], + default=None, help="Export format (default: md)") + parser.add_argument("--session", default=None, + help="Export/stats for single session (UUID prefix)") + + subparsers = parser.add_subparsers(dest="command") + + # List subcommand + list_p = subparsers.add_parser("list", help="List projects and sessions") + list_p.add_argument("--project", default=None, + help="Filter/select project") + list_p.add_argument("--base-dir", default=None, + help="Override Claude Code projects directory") + + # Stats subcommand + stats_p = subparsers.add_parser("stats", help="Show statistics") + stats_p.add_argument("--session", default=None, + help="Stats for specific session (UUID prefix)") + stats_p.add_argument("--format", choices=["text", "json"], default="text", + help="Output format (default: text)") + stats_p.add_argument("--project", default=None, + help="Filter by project name") + stats_p.add_argument("--base-dir", default=None, + help="Override Claude Code projects directory") + + # Export subcommand (explicit) + export_p = subparsers.add_parser("export", help="Export sessions") + export_p.add_argument("--since", choices=["all", "last"], default="all", + help="Export all or only new since last run") + export_p.add_argument("--out", default=None, + help="Output directory (default: current dir)") + export_p.add_argument("--no-zip", action="store_true", + help="Write individual files instead of zip") + export_p.add_argument("--format", choices=["md", "json", "both"], + default="md", help="Export format (default: md)") + export_p.add_argument("--session", default=None, + help="Export single session by UUID prefix") + export_p.add_argument("--project", default=None, + help="Filter by project name") + export_p.add_argument("--base-dir", default=None, + help="Override Claude Code projects directory") + + return parser + + +# ==================== Helpers ==================== + + +def _find_session(session_id: str, base_dir: str) -> str | None: + """Scan all projects for a session matching this UUID (or prefix). + Fails if the prefix matches more than one session.""" + matches = [] + for project in list_projects(base_dir): + for s in list_sessions(project["path"]): + if s["id"] == session_id: + return s["path"] + if s["id"].startswith(session_id): + matches.append(s) + if len(matches) == 1: + return matches[0]["path"] + if len(matches) > 1: + _die( + f"Ambiguous prefix '{session_id}' matches {len(matches)} sessions:\n" + + "\n".join(f" {m['id']}" for m in matches) + ) + return None def _load_state() -> dict: @@ -199,5 +558,10 @@ def _slugify(text: str) -> str: return slug.strip("-") +def _die(msg: str): + print(f"Error: {msg}", file=sys.stderr) + sys.exit(1) + + if __name__ == "__main__": main() diff --git a/static/css/style.css b/static/css/style.css index 11d724a..1bf8d95 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,137 +1,415 @@ +/* ========== Theme Variables ========== */ + :root { - --bg: #1a1a2e; - --bg-card: #16213e; - --bg-hover: #0f3460; - --bg-sidebar: #12192e; - --text: #e0e0e0; - --text-muted: #888; - --accent: #4fc3f7; - --accent-hover: #81d4fa; - --success: #66bb6a; - --danger: #ef5350; - --border: #2a2a4a; - --code-bg: #0d1117; - --user-bg: #1a3050; - --assistant-bg: #1e2a3a; - --system-bg: #2a2a2a; - --thinking-bg: #1a2a1a; - --tool-bg: #2a1a2a; - --active-tab: #1a3a5c; + --bg: #0f0f1a; + --bg-card: #181825; + --bg-hover: #1e1e32; + --bg-sidebar: #14141f; + --bg-elevated: #1f1f33; + --text: #e2e2ec; + --text-muted: #8888a0; + --text-heading: #f0f0f8; + --accent: #7c6cf0; + --accent-hover: #9588f7; + --accent-subtle: rgba(124, 108, 240, 0.12); + --success: #4ade80; + --danger: #f87171; + --border: rgba(255, 255, 255, 0.06); + --shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); + --code-bg: #0d0d18; + --user-bg: #161630; + --assistant-bg: #181828; + --system-bg: #1a1a28; + --thinking-bg: #141a22; + --tool-bg: #1a1424; + --active-tab: rgba(124, 108, 240, 0.15); + --radius: 12px; + --radius-sm: 8px; } [data-theme="light"] { - --bg: #f5f5f5; + --bg: #f4f5fa; --bg-card: #ffffff; - --bg-hover: #e8e8e8; - --bg-sidebar: #f0f0f0; - --text: #333333; - --text-muted: #666; - --accent: #1976d2; - --accent-hover: #1565c0; - --success: #2e7d32; - --danger: #c62828; - --border: #ddd; - --code-bg: #f6f8fa; - --user-bg: #e3f2fd; - --assistant-bg: #f5f5f5; - --system-bg: #eeeeee; - --thinking-bg: #e8f5e9; - --tool-bg: #fce4ec; - --active-tab: #bbdefb; + --bg-hover: #eef0f6; + --bg-sidebar: #f8f9fc; + --bg-elevated: #ffffff; + --text: #1e1e2e; + --text-muted: #6b6b80; + --text-heading: #111122; + --accent: #4f46e5; + --accent-hover: #4338ca; + --accent-subtle: rgba(79, 70, 229, 0.08); + --success: #16a34a; + --danger: #dc2626; + --border: rgba(0, 0, 0, 0.06); + --shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.08); + --code-bg: #f6f7fb; + --user-bg: #eef2ff; + --assistant-bg: #f8f8fc; + --system-bg: #f0f0f4; + --thinking-bg: #f0f9f0; + --tool-bg: #faf0fc; + --active-tab: rgba(79, 70, 229, 0.1); } +/* ========== Reset & Base ========== */ + * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -/* Header */ +/* ========== Header ========== */ + header { display: flex; justify-content: space-between; align-items: center; - padding: 12px 24px; + padding: 0 24px; + height: 56px; background: var(--bg-card); border-bottom: 1px solid var(--border); + backdrop-filter: blur(12px); position: sticky; top: 0; z-index: 100; } -header h1 { font-size: 1.1rem; font-weight: 700; } -header h1 a { color: var(--text); text-decoration: none; } +header h1 { font-size: 0.95rem; font-weight: 600; letter-spacing: -0.01em; } +header h1 a { color: var(--text); text-decoration: none; transition: color 0.2s; } header h1 a:hover { color: var(--accent); } -.header-right { display: flex; gap: 12px; align-items: center; } +.header-right { display: flex; gap: 8px; align-items: center; } .icon-btn { color: var(--text-muted); cursor: pointer; - padding: 4px; + padding: 8px; display: flex; align-items: center; text-decoration: none; + border-radius: var(--radius-sm); + transition: all 0.2s; } -.icon-btn:hover { color: var(--text); } +.icon-btn:hover { color: var(--text); background: var(--bg-hover); } + +/* ========== Buttons ========== */ -/* Buttons */ button, .btn { - padding: 6px 14px; + padding: 8px 16px; border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text); cursor: pointer; font-family: inherit; - font-size: 0.85rem; + font-size: 0.82rem; + font-weight: 500; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; + transition: all 0.2s; +} +button:hover, .btn:hover { + background: var(--bg-hover); + border-color: var(--accent); +} + +.btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} +.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); } -button:hover, .btn:hover { background: var(--bg-hover); } .btn-outline { background: transparent; } -.btn-sm { padding: 4px 10px; font-size: 0.8rem; } +.btn-sm { padding: 6px 12px; font-size: 0.78rem; } + +/* ========== Layout ========== */ main { max-width: 1200px; margin: 0 auto; - padding: 24px; + padding: 32px 24px; } -.loading { text-align: center; padding: 48px; color: var(--text-muted); } +/* ========== Loading ========== */ + +.loading { + text-align: center; + padding: 64px; + color: var(--text-muted); + font-size: 0.9rem; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} +.loading { animation: pulse 1.5s ease-in-out infinite; } + +/* Top loading bar */ +.loading-bar { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 3px; + z-index: 12000; + pointer-events: none; + opacity: 0; + transform: scaleX(0); + transform-origin: left; + background: linear-gradient(90deg, var(--accent), var(--accent-hover), var(--accent)); + background-size: 200% 100%; + transition: opacity 0.2s; +} +.loading-bar.active { + opacity: 1; + transform: scaleX(1); + animation: loading-progress 1.8s ease-in-out infinite, loading-shimmer 1.5s linear infinite; +} +.loading-bar.done { + opacity: 0; + transform: scaleX(1); + transition: opacity 0.4s ease; +} +@keyframes loading-progress { + 0% { transform: scaleX(0); } + 20% { transform: scaleX(0.4); } + 50% { transform: scaleX(0.7); } + 80% { transform: scaleX(0.85); } + 100% { transform: scaleX(0.92); } +} +@keyframes loading-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* ========== Fade-in & Content Transitions ========== */ + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.fade-in { + animation: fade-in 0.3s ease-out forwards; +} + +/* Smooth content swap */ +.content-enter { opacity: 0; } +.content-ready { + opacity: 1; + transition: opacity 0.25s ease-out; +} + +/* Staggered card appearance */ +.projects-grid .project-card { + opacity: 0; + animation: card-appear 0.35s ease-out forwards; +} +@keyframes card-appear { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} +.projects-grid .project-card:nth-child(1) { animation-delay: 0.03s; } +.projects-grid .project-card:nth-child(2) { animation-delay: 0.06s; } +.projects-grid .project-card:nth-child(3) { animation-delay: 0.09s; } +.projects-grid .project-card:nth-child(4) { animation-delay: 0.12s; } +.projects-grid .project-card:nth-child(5) { animation-delay: 0.15s; } +.projects-grid .project-card:nth-child(6) { animation-delay: 0.18s; } +.projects-grid .project-card:nth-child(7) { animation-delay: 0.21s; } +.projects-grid .project-card:nth-child(8) { animation-delay: 0.24s; } +.projects-grid .project-card:nth-child(9) { animation-delay: 0.27s; } +.projects-grid .project-card:nth-child(n+10) { animation-delay: 0.3s; } + +/* Messages fade in subtly */ +.messages-container .message { + opacity: 0; + animation: msg-appear 0.3s ease-out forwards; +} +@keyframes msg-appear { + from { opacity: 0; } + to { opacity: 1; } +} +.messages-container .message:nth-child(-n+5) { animation-delay: calc(var(--i, 0) * 0.04s); } +.messages-container .message:nth-child(1) { --i: 1; } +.messages-container .message:nth-child(2) { --i: 2; } +.messages-container .message:nth-child(3) { --i: 3; } +.messages-container .message:nth-child(4) { --i: 4; } +.messages-container .message:nth-child(5) { --i: 5; } +.messages-container .message:nth-child(n+6) { animation-delay: 0.2s; } + +/* ========== Hero Section (landing page) ========== */ + +.hero { + text-align: center; + padding: 48px 0 40px; + animation: fade-in 0.4s ease-out; +} + +.hero h1 { + font-size: 2rem; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--text-heading); + margin-bottom: 8px; +} + +.hero p { + color: var(--text-muted); + font-size: 1rem; + margin-bottom: 28px; +} + +.hero-stats { + display: flex; + justify-content: center; + gap: 32px; + margin-bottom: 28px; +} + +.hero-stat { + text-align: center; +} + +.hero-stat .value { + font-size: 1.6rem; + font-weight: 700; + color: var(--accent); + letter-spacing: -0.02em; +} + +.hero-stat .label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +/* ========== Project Card Grid ========== */ + +.projects-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +@media (max-width: 900px) { + .projects-grid { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 560px) { + .projects-grid { grid-template-columns: 1fr; } +} + +.project-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + color: var(--text); + display: block; +} + +.project-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); + border-color: var(--accent); +} + +.project-card .card-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-heading); + margin-bottom: 10px; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.project-card .card-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.project-card .session-badge { + font-size: 0.75rem; + font-weight: 600; + color: var(--accent); + background: var(--accent-subtle); + padding: 3px 10px; + border-radius: 20px; +} + +.project-card .card-date { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* ========== Page Header (used by older views) ========== */ -/* Page header */ .page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; } -.page-header h1 { font-size: 1.4rem; margin-bottom: 4px; } +.page-header h1 { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-heading); + margin-bottom: 4px; +} .page-header .btn-group { display: flex; gap: 8px; } .text-muted { color: var(--text-muted); } .text-sm { font-size: 0.8rem; } .text-success { color: var(--success); } -/* Card */ +/* ========== Card (generic) ========== */ + .card { background: var(--bg-card); border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius); overflow: hidden; + box-shadow: var(--shadow); +} +.card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} +.card-header h2 { + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.01em; + margin-bottom: 2px; } -.card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); } -.card-header h2 { font-size: 1.1rem; margin-bottom: 2px; } .card-body { padding: 0; } -/* Table */ +/* ========== Table ========== */ + .table { width: 100%; border-collapse: collapse; @@ -139,7 +417,7 @@ main { .table th { text-align: left; padding: 10px 20px; - font-size: 0.8rem; + font-size: 0.72rem; color: var(--text-muted); font-weight: 600; text-transform: uppercase; @@ -148,17 +426,20 @@ main { .table td { padding: 12px 20px; border-top: 1px solid var(--border); + font-size: 0.88rem; } +.table tr { transition: background 0.15s; } .table tr:hover td { background: var(--bg-hover); } -.table a { color: var(--accent); text-decoration: none; } +.table a { color: var(--accent); text-decoration: none; font-weight: 500; } .table a:hover { text-decoration: underline; } -/* Workspace / split layout */ +/* ========== Workspace / Split Layout ========== */ + .workspace-layout { display: flex; gap: 0; min-height: calc(100vh - 120px); - margin: -24px; + margin: -32px -24px; } .sidebar { @@ -167,198 +448,745 @@ main { background: var(--bg-sidebar); border-right: 1px solid var(--border); overflow-y: auto; - max-height: calc(100vh - 60px); + max-height: calc(100vh - 56px); position: sticky; - top: 60px; + top: 56px; } .sidebar-header { - padding: 16px; + padding: 14px 16px; border-bottom: 1px solid var(--border); font-weight: 600; - font-size: 0.9rem; + font-size: 0.85rem; } .sidebar-item { padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; - transition: background 0.15s; + transition: all 0.15s; + min-width: 0; + overflow: hidden; } .sidebar-item:hover { background: var(--bg-hover); } -.sidebar-item.active { background: var(--active-tab); border-left: 3px solid var(--accent); } -.sidebar-item .title { font-weight: 600; font-size: 0.9rem; margin-bottom: 2px; } -.sidebar-item .meta { color: var(--text-muted); font-size: 0.75rem; } +.sidebar-item.active { + background: var(--active-tab); + border-left: 3px solid var(--accent); +} +.sidebar-item .title { + font-weight: 600; + font-size: 0.85rem; + margin-bottom: 3px; + color: var(--text-heading); + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; + min-width: 0; +} +.sidebar-item .meta { + color: var(--text-muted); + font-size: 0.72rem; + line-height: 1.5; +} +.sidebar-item-error .title { color: var(--danger); } +.sidebar-item-error .error-detail { + color: var(--text-muted); + font-size: 0.68rem; + margin-bottom: 3px; + word-break: break-word; +} .main-panel { flex: 1; overflow-y: auto; - max-height: calc(100vh - 60px); + max-height: calc(100vh - 56px); } .panel-header { - padding: 20px 24px; + padding: 20px 24px 16px; border-bottom: 1px solid var(--border); background: var(--bg-card); display: flex; justify-content: space-between; align-items: flex-start; + gap: 16px; + animation: fade-in 0.3s ease-out; +} +.panel-header-left { + flex: 1; + min-width: 0; } -.panel-header h2 { font-size: 1.2rem; margin-bottom: 4px; } -.panel-header .stats { color: var(--text-muted); font-size: 0.8rem; } -.panel-header .btn-group { display: flex; gap: 8px; } +.panel-title { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-heading); + margin-bottom: 6px; + line-height: 1.3; + word-break: break-word; + overflow-wrap: anywhere; +} +.panel-subtitle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-bottom: 10px; + font-size: 0.78rem; + color: var(--text-muted); +} +.panel-time { + opacity: 0.85; +} +.panel-msg-count { + padding: 1px 8px; + border-radius: 10px; + background: var(--accent-subtle); + color: var(--accent); + font-weight: 500; + font-size: 0.72rem; +} +.panel-header .stats { + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.6; +} +.stat-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.stat-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 20px; + font-size: 0.72rem; + font-weight: 500; + white-space: nowrap; + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text-muted); +} +.stat-badge.badge-model { + background: rgba(124, 108, 240, 0.12); + color: var(--accent); + border-color: rgba(124, 108, 240, 0.2); +} +.stat-badge.badge-tokens { + background: rgba(74, 222, 128, 0.10); + color: var(--success); + border-color: rgba(74, 222, 128, 0.18); +} +.stat-badge.badge-tools { + background: rgba(251, 191, 36, 0.10); + color: #fbbf24; + border-color: rgba(251, 191, 36, 0.18); +} +.stat-badge.badge-compact { + background: rgba(248, 113, 113, 0.10); + color: var(--danger); + border-color: rgba(248, 113, 113, 0.18); +} +.stat-badge.badge-dir { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; +} +.stat-badge.badge-branch { + background: rgba(56, 189, 248, 0.10); + color: #38bdf8; + border-color: rgba(56, 189, 248, 0.18); +} +.stat-badge.badge-version { + background: var(--bg-elevated); + color: var(--text-muted); +} +.stat-badge.badge-perm { + background: var(--bg-elevated); + color: var(--text-muted); +} +[data-theme="light"] .stat-badge.badge-tools { color: #b45309; } +[data-theme="light"] .stat-badge.badge-branch { color: #0284c7; } +.panel-header .btn-group { display: flex; gap: 8px; flex-shrink: 0; white-space: nowrap; } -/* Project info card at top of workspace */ +/* Project info card in workspace */ .project-info { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 8px; + margin: 20px 24px; padding: 20px 24px; - margin: 24px; + background: linear-gradient(135deg, var(--accent-subtle), transparent 60%); + border: 1px solid var(--border); + border-radius: var(--radius); + position: relative; + overflow: hidden; +} +.project-info::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: var(--radius) var(--radius) 0 0; } -.project-info h2 { font-size: 1.2rem; margin-bottom: 4px; } -.project-info .meta { color: var(--text-muted); font-size: 0.85rem; } +.project-info h2 { + font-size: 1.3rem; + font-weight: 800; + color: var(--text-heading); + margin: 0 0 6px 0; + letter-spacing: -0.02em; +} +.project-info .meta { + color: var(--text-muted); + font-size: 0.8rem; +} + +/* ========== Messages ========== */ -/* Messages */ -.messages-container { padding: 16px 24px; } +.messages-container { padding: 16px 24px; animation: fade-in 0.3s ease-out; } .message { margin-bottom: 12px; - padding: 14px 16px; - border-radius: 8px; + padding: 16px 18px; + border-radius: var(--radius-sm); border-left: 3px solid transparent; + transition: background 0.15s; } .message.user { background: var(--user-bg); - border-left-color: #42a5f5; + border-left-color: #6366f1; } .message.assistant { background: var(--assistant-bg); - border-left-color: #ab47bc; + border-left-color: #a78bfa; } .message.system { background: var(--system-bg); - border-left-color: #888; + border-left-color: #525270; font-style: italic; - font-size: 0.85rem; + font-size: 0.83rem; } .message .role-badge { display: inline-block; - padding: 2px 8px; - border-radius: 12px; - font-size: 0.7rem; - font-weight: 700; + padding: 2px 10px; + border-radius: 20px; + font-size: 0.68rem; + font-weight: 600; text-transform: uppercase; - margin-bottom: 6px; + letter-spacing: 0.3px; + margin-bottom: 8px; } -.role-badge.user-badge { background: #1565c0; color: white; } -.role-badge.assistant-badge { background: #7b1fa2; color: white; } +.role-badge.user-badge { background: rgba(99, 102, 241, 0.2); color: #818cf8; } +.role-badge.assistant-badge { background: rgba(167, 139, 250, 0.2); color: #c4b5fd; } + +[data-theme="light"] .role-badge.user-badge { background: rgba(79, 70, 229, 0.1); color: #4f46e5; } +[data-theme="light"] .role-badge.assistant-badge { background: rgba(139, 92, 246, 0.1); color: #7c3aed; } .message .msg-meta { color: var(--text-muted); - font-size: 0.75rem; + font-size: 0.72rem; margin-bottom: 8px; } -.message .content { white-space: pre-wrap; word-break: break-word; font-size: 0.9rem; } +.message .content { + white-space: pre-wrap; + word-break: normal; + overflow-wrap: anywhere; + font-size: 0.88rem; + line-height: 1.65; +} +.msg-image { margin: 8px 0; } +.msg-image img { + max-width: 100%; + max-height: 600px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} + +/* ========== Thinking Block ========== */ -/* Thinking block */ .thinking-block { background: var(--thinking-bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-sm); margin: 8px 0; - padding: 8px 12px; - font-size: 0.85rem; + padding: 10px 14px; + font-size: 0.83rem; +} +.thinking-block summary { + cursor: pointer; + color: var(--text-muted); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.3px; } -.thinking-block summary { cursor: pointer; color: var(--text-muted); font-weight: 600; } -/* Tool call */ +/* ========== Tool Call ========== */ + .tool-call { background: var(--tool-bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-sm); margin: 8px 0; - padding: 10px 12px; - font-size: 0.85rem; + font-size: 0.83rem; +} +.tool-call > summary.tool-name { + font-weight: 600; + color: var(--accent); + font-size: 0.78rem; + padding: 10px 14px; + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + gap: 6px; +} +.tool-call > summary.tool-name::before { + content: '\25B6'; + font-size: 0.6rem; + transition: transform 0.15s; +} +.tool-call[open] > summary.tool-name::before { + transform: rotate(90deg); +} +.tool-call > summary.tool-name::-webkit-details-marker { display: none; } +.tool-call-body { + padding: 0 14px 12px; + border-top: 1px solid var(--border); +} +.tool-call-section { + margin-top: 8px; + font-size: 0.8rem; +} +.tool-call-section-title { + font-weight: 600; + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.3px; + margin-bottom: 4px; +} + +/* Tool Result */ +.tool-result { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + margin: 4px 0; + font-size: 0.8rem; } -.tool-call .tool-name { font-weight: 700; color: var(--accent); margin-bottom: 4px; } +.tool-result-summary { + padding: 8px 14px; + color: var(--text-muted); + font-size: 0.78rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; +} +details.tool-result > summary.tool-result-summary::before { + content: '\25B6'; + font-size: 0.55rem; + transition: transform 0.15s; +} +details.tool-result[open] > summary.tool-result-summary::before { + transform: rotate(90deg); +} +details.tool-result > summary.tool-result-summary::-webkit-details-marker { display: none; } +.tool-result:not(details) .tool-result-summary { + cursor: default; +} + +/* ========== Code ========== */ -/* Code */ pre { background: var(--code-bg); border: 1px solid var(--border); - border-radius: 4px; - padding: 10px; + border-radius: var(--radius-sm); + padding: 12px 14px; overflow-x: auto; - font-size: 0.82rem; + font-size: 0.8rem; margin: 6px 0; - font-family: 'Cascadia Code', 'Fira Code', monospace; + font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace; + line-height: 1.5; } code { background: var(--code-bg); - padding: 1px 4px; - border-radius: 3px; - font-size: 0.88em; - font-family: 'Cascadia Code', 'Fira Code', monospace; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.85em; + font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace; } pre code { background: none; padding: 0; } -/* Search page */ -.search-page { max-width: 800px; margin: 0 auto; } +/* ========== Search Page ========== */ + +.search-page { + max-width: 720px; + margin: 0 auto; + animation: fade-in 0.3s ease-out; +} +.search-page h1 { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text-heading); +} .search-page input { width: 100%; - padding: 10px 16px; + padding: 12px 18px; border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text); - font-size: 1rem; + font-size: 0.95rem; font-family: inherit; - margin-bottom: 16px; + margin: 16px 0; + transition: border-color 0.2s; + outline: none; } +.search-page input:focus { border-color: var(--accent); } + .search-results { display: flex; flex-direction: column; gap: 8px; } .search-result { background: var(--bg-card); border: 1px solid var(--border); - border-radius: 6px; - padding: 14px; + border-radius: var(--radius-sm); + padding: 14px 16px; cursor: pointer; + transition: all 0.2s; } -.search-result:hover { border-color: var(--accent); } -.search-result .snippet { color: var(--text-muted); font-size: 0.85rem; margin-top: 6px; } +.search-result:hover { + border-color: var(--accent); + transform: translateY(-1px); + box-shadow: var(--shadow); +} +.search-result .snippet { + color: var(--text-muted); + font-size: 0.82rem; + margin-top: 6px; +} + +/* ========== Back Link ========== */ -/* Back button */ .back-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); text-decoration: none; - font-size: 0.85rem; + font-size: 0.82rem; + font-weight: 500; margin-bottom: 16px; cursor: pointer; + transition: color 0.2s; } -.back-link:hover { color: var(--text); } +.back-link:hover { color: var(--accent); } + +/* ========== Date Groups in Sidebar ========== */ -/* Date groups in sidebar */ .date-label { - padding: 6px 16px; - font-size: 0.7rem; + padding: 8px 16px 4px; + font-size: 0.68rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; - background: var(--bg); } -/* Empty state */ +/* ========== Empty State ========== */ + .empty-state { text-align: center; - padding: 60px 24px; + padding: 64px 24px; + color: var(--text-muted); + font-size: 0.9rem; +} + +/* ========== Hamburger (hidden on desktop) ========== */ + +.hamburger { + display: none; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 6px; + border-radius: var(--radius-sm); + transition: all 0.2s; + line-height: 0; +} +.hamburger:hover { color: var(--text); background: var(--bg-hover); } + +.header-left { display: flex; align-items: center; gap: 8px; } + +/* Sidebar overlay backdrop */ +.sidebar-overlay { + display: none; + position: fixed; + inset: 0; + top: 56px; + background: rgba(0, 0, 0, 0.5); + z-index: 49; +} +.sidebar-overlay.active { display: block; } + +/* ========== Responsive ========== */ + +@media (max-width: 768px) { + .hamburger { display: flex; } + + .workspace-layout { position: relative; } + + .sidebar { + position: fixed; + top: 56px; + left: 0; + bottom: 0; + width: 300px; + min-width: 300px; + z-index: 50; + transform: translateX(-100%); + transition: transform 0.25s ease; + overflow-y: auto; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + } + .sidebar.open { transform: translateX(0); } + + .main-panel { width: 100%; max-height: none; } + + .project-info { margin: 12px 14px; padding: 14px 16px; } + .project-info h2 { font-size: 1.1rem; } + + .panel-header { padding: 14px 16px 12px; flex-direction: column; gap: 10px; } + .panel-title { font-size: 1rem; } + .stat-badges { gap: 4px; } + .stat-badge { font-size: 0.65rem; padding: 2px 7px; } + + .messages-container { padding: 12px 14px; } + .message { padding: 12px 14px; } + + header { padding: 0 14px; } + .container { padding: 16px 12px; } +} + +/* ========== Toast Notifications ========== */ + +.toast { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-radius: var(--radius); + font-size: 0.85rem; + font-weight: 500; + color: var(--text); + background: var(--bg-elevated); + border: 1px solid var(--border); + box-shadow: var(--shadow-lg); + z-index: 10000; + opacity: 0; + transform: translateY(16px) scale(0.96); + transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + pointer-events: none; + max-width: 400px; + min-width: 240px; + overflow: hidden; +} +.toast.show { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; +} +.toast-icon { + flex-shrink: 0; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + font-weight: 700; +} +.toast-success .toast-icon { background: rgba(74, 222, 128, 0.15); color: var(--success); } +.toast-error .toast-icon { background: rgba(248, 113, 113, 0.15); color: var(--danger); } +.toast-info .toast-icon { background: var(--accent-subtle); color: var(--accent); } +.toast-text { flex: 1; line-height: 1.4; } +.toast-close { + flex-shrink: 0; + background: none; + border: none; + color: var(--text-muted); + font-size: 1.1rem; + cursor: pointer; + padding: 0 2px; + line-height: 1; + transition: color 0.15s; +} +.toast-close:hover { color: var(--text); } +.toast-progress { + position: absolute; + bottom: 0; + left: 0; + height: 3px; + border-radius: 0 0 var(--radius) var(--radius); + width: 100%; + transform-origin: left; + animation: toast-shrink 3.5s linear forwards; +} +.toast-success .toast-progress { background: var(--success); } +.toast-error .toast-progress { background: var(--danger); } +.toast-info .toast-progress { background: var(--accent); } +@keyframes toast-shrink { from { transform: scaleX(1); } to { transform: scaleX(0); } } + +/* ========== Confirm Modal ========== */ + +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 11000; + opacity: 0; + transition: opacity 0.2s ease; +} +.confirm-overlay.show { opacity: 1; } +.confirm-overlay.show .confirm-dialog { transform: scale(1); } + +.confirm-dialog { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + min-width: 360px; + max-width: 440px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); + transform: scale(0.92); + transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.confirm-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.confirm-icon { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--accent-subtle); + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + font-weight: 700; + flex-shrink: 0; +} + +.confirm-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--text-heading); +} + +.confirm-message { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0 0 24px; + line-height: 1.5; + padding-left: 48px; +} + +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.confirm-btn { + padding: 9px 20px; + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); + transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s; + outline: none; +} +.confirm-btn:focus-visible { + box-shadow: 0 0 0 2px var(--accent-subtle); +} + +.confirm-cancel { + background: var(--bg-hover); color: var(--text-muted); } +.confirm-cancel:hover { + background: var(--border); + color: var(--text); +} + +.confirm-ok { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.confirm-ok:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +/* ========== Scroll to Top Button ========== */ + +.scroll-top-btn { + position: fixed; + bottom: 24px; + right: 24px; + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text-muted); + font-size: 1.2rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + opacity: 0; + transform: translateY(12px); + transition: opacity 0.3s ease, transform 0.3s ease, color 0.2s, background 0.2s; + pointer-events: none; + z-index: 9000; +} +.scroll-top-btn.show { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} +.scroll-top-btn:hover { + color: var(--accent); + background: var(--bg-hover); +} diff --git a/static/index.html b/static/index.html index 955e001..7645fba 100644 --- a/static/index.html +++ b/static/index.html @@ -4,11 +4,17 @@ Claude Code Chat Browser + + +
diff --git a/static/js/app.js b/static/js/app.js index 8f5464f..7fb5514 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,5 +1,70 @@ // Claude Code Chat Browser — Main JS +function showToast(message, type = 'info') { + const icons = { success: '\u2713', error: '\u2717', info: '\u2139' }; + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.innerHTML = `${icons[type] || icons.info}${message}
`; + document.body.appendChild(toast); + toast.querySelector('.toast-close').addEventListener('click', () => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }); + requestAnimationFrame(() => toast.classList.add('show')); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 3500); +} + +function showConfirm(message, onConfirm) { + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + const dialog = document.createElement('div'); + dialog.className = 'confirm-dialog'; + dialog.innerHTML = ` +
+ ? + Confirm Action +
+

${message}

+
+ + +
`; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('show')); + const close = () => { overlay.classList.remove('show'); setTimeout(() => overlay.remove(), 200); document.removeEventListener('keydown', onKey); }; + const onKey = (e) => { if (e.key === 'Escape') close(); if (e.key === 'Enter') { close(); onConfirm(); } }; + document.addEventListener('keydown', onKey); + dialog.querySelector('.confirm-cancel').addEventListener('click', close); + dialog.querySelector('.confirm-ok').addEventListener('click', () => { close(); onConfirm(); }); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + dialog.querySelector('.confirm-ok').focus(); +} + +// Top loading bar +const _loadingBar = (() => { + const bar = document.createElement('div'); + bar.className = 'loading-bar'; + document.documentElement.appendChild(bar); + return { + start() { bar.classList.remove('done'); bar.classList.add('active'); }, + done() { bar.classList.remove('active'); bar.classList.add('done'); setTimeout(() => bar.classList.remove('done'), 400); } + }; +})(); + +// Smooth content swap — fades out old content, swaps HTML, fades in new content +function smoothSet(el, html) { + el.classList.remove('content-ready'); + el.classList.add('content-enter'); + // Force reflow so the browser registers the starting state + void el.offsetHeight; + el.innerHTML = html; + requestAnimationFrame(() => { + el.classList.remove('content-enter'); + el.classList.add('content-ready'); + }); +} + let currentProject = null; let cachedSessions = []; let projectDisplayNames = {}; @@ -8,9 +73,59 @@ document.addEventListener('DOMContentLoaded', () => { applyTheme(localStorage.getItem('theme') || 'dark'); handleRoute(); window.addEventListener('hashchange', handleRoute); + + // Create sidebar overlay element for mobile + const overlay = document.createElement('div'); + overlay.className = 'sidebar-overlay'; + overlay.id = 'sidebar-overlay'; + overlay.addEventListener('click', closeSidebar); + document.body.appendChild(overlay); + + // Scroll-to-top button — listens to .main-panel or window (whichever scrolls) + const topBtn = document.createElement('button'); + topBtn.className = 'scroll-top-btn'; + topBtn.id = 'scroll-top-btn'; + topBtn.textContent = '\u2191'; + topBtn.addEventListener('click', () => { + const panel = document.querySelector('.main-panel'); + if (panel && panel.scrollTop > 0) { panel.scrollTo({ top: 0, behavior: 'smooth' }); } + else { window.scrollTo({ top: 0, behavior: 'smooth' }); } + }); + document.body.appendChild(topBtn); + + const updateScrollBtn = () => { + const panel = document.querySelector('.main-panel'); + const scrollY = Math.max(panel ? panel.scrollTop : 0, window.scrollY); + topBtn.classList.toggle('show', scrollY > 400); + }; + window.addEventListener('scroll', updateScrollBtn, true); + window.addEventListener('scroll', updateScrollBtn); }); +function toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + if (!sidebar) return; + sidebar.classList.toggle('open'); + if (overlay) overlay.classList.toggle('active', sidebar.classList.contains('open')); +} + +function closeSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + if (sidebar) sidebar.classList.remove('open'); + if (overlay) overlay.classList.remove('active'); +} + +function setHamburgerVisible(visible) { + const btn = document.getElementById('hamburger-btn'); + if (btn) btn.style.display = visible ? '' : 'none'; +} + +let _navInProgress = false; + function handleRoute() { + if (_navInProgress) return; const hash = window.location.hash || '#'; if (hash.startsWith('#project/')) { const parts = hash.slice(9); @@ -41,39 +156,26 @@ function handleRoute() { async function showProjects() { currentProject = null; - window.location.hash = ''; + setHamburgerVisible(false); + if (window.location.hash && window.location.hash !== '#') { + _navInProgress = true; + window.location.hash = ''; + setTimeout(() => { _navInProgress = false; }, 0); + } const content = document.getElementById('content'); content.innerHTML = '
Loading projects...
'; + _loadingBar.start(); try { const res = await fetch('/api/projects'); const projects = await res.json(); + _loadingBar.done(); if (!projects.length) { content.innerHTML = '
No Claude Code projects found.
Make sure Claude Code has been used on this machine.
'; return; } - let html = ` - `; - - html += `
-

Projects with Sessions

-

${projects.length} project${projects.length !== 1 ? 's' : ''} with chat history

-
- `; - // Cache display names for (const p of projects) { projectDisplayNames[p.name] = p.display_name || p.name; @@ -82,17 +184,48 @@ async function showProjects() { // Sort by last modified desc projects.sort((a, b) => (b.last_modified || '').localeCompare(a.last_modified || '')); + // Aggregate stats for hero + const totalSessions = projects.reduce((s, p) => s + (p.session_count || 0), 0); + + // Hero section + let html = `
+

Claude Code Sessions

+

Browse and export your conversation history

+
+
+
${projects.length}
+
Projects
+
+
+
${totalSessions}
+
Sessions
+
+
+ +
`; + + // Project cards grid + html += '
'; for (const p of projects) { - html += `
- - - - `; + const displayName = p.display_name || p.name; + const modified = p.last_modified ? formatDate(p.last_modified) : ''; + const count = p.session_count || 0; + html += ` +
${esc(displayName)}
+ +
`; } + html += ''; - html += '
ProjectSessionsLast Modified
${esc(p.display_name || p.name)}${p.session_count} session${p.session_count !== 1 ? 's' : ''}${p.last_modified ? formatTs(p.last_modified) : '—'}
'; - content.innerHTML = html; + smoothSet(content, html); } catch (e) { + _loadingBar.done(); content.innerHTML = `
Error: ${esc(e.message)}
`; } } @@ -101,8 +234,10 @@ async function showProjects() { async function showWorkspace(projectName, selectedSessionId) { currentProject = projectName; + setHamburgerVisible(true); const content = document.getElementById('content'); content.innerHTML = '
Loading sessions...
'; + _loadingBar.start(); try { // Ensure display name is cached @@ -116,18 +251,18 @@ async function showWorkspace(projectName, selectedSessionId) { const res = await fetch(`/api/projects/${encodeURIComponent(projectName)}/sessions`); cachedSessions = await res.json(); - // Sort by first_timestamp desc + // Sort by last_timestamp desc (most recently active first) cachedSessions.sort((a, b) => { - const ta = a.first_timestamp || ''; - const tb = b.first_timestamp || ''; + const ta = a.last_timestamp || a.first_timestamp || ''; + const tb = b.last_timestamp || b.first_timestamp || ''; return tb.localeCompare(ta); }); - // Group by date + // Group by last-active date const byDate = {}; for (const s of cachedSessions) { - const ts = s.first_timestamp || ''; - const date = ts.slice(0, 10) || 'Unknown'; + const ts = s.last_timestamp || s.first_timestamp || ''; + const date = ts ? formatDate(ts) : 'Unknown'; if (!byDate[date]) byDate[date] = []; byDate[date].push(s); } @@ -142,12 +277,15 @@ async function showWorkspace(projectName, selectedSessionId) { for (const date of dates) { sidebar += `
${esc(date)}
`; for (const s of byDate[date]) { - const title = (s.title || s.id).slice(0, 40); - const ts = s.first_timestamp ? formatTs(s.first_timestamp) : ''; + const title = s.title || s.id; + const ts = formatTs(s.last_timestamp || s.first_timestamp || ''); const models = (s.models || []).join(', '); const isActive = s.id === selectedSessionId ? ' active' : ''; - sidebar += `
'; - content.innerHTML = html; + smoothSet(content, html); + _loadingBar.done(); // Auto-select first session or specified session if (selectedSessionId) { @@ -174,11 +313,13 @@ async function showWorkspace(projectName, selectedSessionId) { selectSession(projectName, cachedSessions[0].id); } } catch (e) { + _loadingBar.done(); content.innerHTML = `
Error: ${esc(e.message)}
`; } } function selectSession(projectName, sessionId) { + closeSidebar(); // Just update the hash — handleRoute will do the rest window.location.hash = `#project/${encodeURIComponent(projectName)}/${sessionId}`; } @@ -186,29 +327,58 @@ function selectSession(projectName, sessionId) { async function loadSession(projectName, sessionId) { const container = document.getElementById('session-content'); if (!container) return; + _loadingBar.start(); try { const res = await fetch(`/api/sessions/${encodeURIComponent(projectName)}/${sessionId}`); + if (!res.ok) { + _loadingBar.done(); + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + container.innerHTML = `
Error loading session: ${esc(err.error || res.statusText)}
`; + return; + } const session = await res.json(); + if (session.error) { + _loadingBar.done(); + container.innerHTML = `
Error: ${esc(session.error)}
`; + return; + } const meta = session.metadata; let html = ''; // Panel header + const modelsList = (meta.models_used || []).filter(m => m !== ''); + const totalTokens = (meta.total_input_tokens + meta.total_output_tokens).toLocaleString(); + const msgCount = session.messages.filter(m => m.role === 'user' && m.text && m.text.trim()).length; + + // Subtitle: timestamps + message count + let subtitleParts = []; + if (meta.first_timestamp) subtitleParts.push(formatTs(meta.first_timestamp)); + if (meta.last_timestamp && meta.last_timestamp !== meta.first_timestamp) subtitleParts.push(formatTs(meta.last_timestamp)); + const timeRange = subtitleParts.length === 2 + ? `${subtitleParts[0]} → ${subtitleParts[1]}` + : subtitleParts[0] || ''; + + let badges = ''; + if (modelsList.length === 0) badges += `N/A`; + else modelsList.forEach(m => { badges += `${esc(m)}`; }); + badges += `${totalTokens} tokens`; + badges += `${meta.total_tool_calls} tool calls`; + if (meta.compactions > 0) badges += `${meta.compactions} compaction${meta.compactions > 1 ? 's' : ''}`; + if (meta.cwd) badges += `${esc(meta.cwd)}`; + if (meta.git_branch) badges += `${esc(meta.git_branch)}`; + if (meta.version) badges += `v${esc(meta.version)}`; + if (meta.permission_mode) badges += `${esc(meta.permission_mode)}`; + html += `
-
-

${esc(session.title)}

-
- Models: ${esc((meta.models_used || []).join(', '))} • - Tokens: ${(meta.total_input_tokens + meta.total_output_tokens).toLocaleString()} • - Tool calls: ${meta.total_tool_calls} - ${meta.compactions > 0 ? ' • Compactions: ' + meta.compactions : ''} -
-
- ${meta.cwd ? 'Dir: ' + esc(meta.cwd) + ' • ' : ''} - ${meta.git_branch ? 'Branch: ' + esc(meta.git_branch) + ' • ' : ''} - ${meta.version ? 'v' + esc(meta.version) : ''} +
+

${esc(session.title)}

+
+ ${timeRange} + ${msgCount} message${msgCount !== 1 ? 's' : ''}
+
${badges}
@@ -216,7 +386,7 @@ async function loadSession(projectName, sessionId) {
`; - // Messages + // Messages (chronological: old to new) html += '
'; for (const msg of session.messages) { if (msg.role === 'user') html += renderUser(msg); @@ -225,8 +395,10 @@ async function loadSession(projectName, sessionId) { } html += '
'; - container.innerHTML = html; + smoothSet(container, html); + _loadingBar.done(); } catch (e) { + _loadingBar.done(); container.innerHTML = `
Error: ${esc(e.message)}
`; } } @@ -234,24 +406,50 @@ async function loadSession(projectName, sessionId) { // ==================== Message renderers ==================== function renderUser(msg) { - if (msg.slug && !msg.text) return ''; + const hasText = msg.text && msg.text.trim(); + const hasImages = msg.images && msg.images.length > 0; + const hasToolResult = msg.tool_result_parsed; + + // Skip messages with no visible content + if (!hasText && !hasImages && !hasToolResult) return ''; + + // Tool result messages: only render if they have meaningful expandable content + // (bash stdout/stderr, todo items, user Q&A). Skip summary-only results like + // "Glob: 0 files found" since the tool call already shows what happened. + if (hasToolResult && !hasText && !hasImages) { + if (_toolResultHasBody(msg.tool_result_parsed)) { + return renderToolResult(msg.tool_result_parsed); + } + return ''; + } + let html = `
`; html += `You`; if (msg.timestamp) html += ` ${formatTs(msg.timestamp)}`; - html += `
${escContent(msg.text || '')}
`; + if (hasImages) { + for (const img of msg.images) { + html += `
User image
`; + } + } + if (hasText) html += `
${escContent(msg.text)}
`; + if (hasToolResult) html += renderToolResult(msg.tool_result_parsed); html += '
'; return html; } function renderAssistant(msg) { + const hasText = msg.text && msg.text.trim(); + const hasThinking = msg.thinking && msg.thinking.trim(); + const hasTools = msg.tool_uses && msg.tool_uses.length > 0; + if (!hasText && !hasThinking && !hasTools) return ''; let html = `
`; html += `Assistant`; let metaParts = []; - if (msg.model) metaParts.push(msg.model); + if (msg.model && msg.model !== '') metaParts.push(msg.model); if (msg.usage && msg.usage.output_tokens) metaParts.push(`${msg.usage.output_tokens.toLocaleString()} tokens`); if (msg.timestamp) metaParts.push(formatTs(msg.timestamp)); - if (metaParts.length) html += ` ${esc(metaParts.join(' • '))}`; + if (metaParts.length) html += ` ${metaParts.map(esc).join(' • ')}`; if (msg.thinking) { html += `
Thinking
${escContent(msg.thinking)}
`; @@ -274,51 +472,154 @@ function renderSystem(msg) { return ''; } +function getToolSummary(name, inp) { + if (name === 'Bash') return `Bash: ${truncate(inp.command || '', 80)}`; + if (name === 'Read') return `Read: ${esc(inp.file_path || '')}`; + if (name === 'Write') return `Write: ${esc(inp.file_path || '')}`; + if (name === 'Edit') return `Edit: ${esc(inp.file_path || '')}`; + if (name === 'Glob') return `Glob: ${esc(inp.pattern || '')}`; + if (name === 'Grep') return `Grep: /${esc(inp.pattern || '')}/` + (inp.path ? ` in ${esc(inp.path)}` : ''); + if (name === 'WebFetch') return `Fetch: ${truncate(inp.url || '', 80)}`; + if (name === 'WebSearch') return `Search: ${truncate(inp.query || '', 80)}`; + if (name === 'Task') return `Task: ${esc(inp.subagent_type || '')} - ${esc(inp.description || '')}`; + if (name === 'TodoWrite') return 'TodoWrite'; + if (name === 'AskUserQuestion') return 'AskUserQuestion'; + return name; +} + function renderToolUse(tool) { const name = tool.name || 'unknown'; const inp = tool.input || {}; - let html = `
${esc(name)}
`; + const summary = getToolSummary(name, inp); + + let body = ''; if (name === 'Bash') { - html += `
${esc(inp.command || '')}
`; + body += `
Command
${esc(inp.command || '')}
`; + if (inp.description) body += `
Description
${esc(inp.description)}
`; } else if (name === 'Read') { - html += `
File: ${esc(inp.file_path || '')}
`; + body += `
File: ${esc(inp.file_path || '')}
`; } else if (name === 'Write') { - html += `
File: ${esc(inp.file_path || '')}
`; - if (inp.content) html += `
${esc(truncate(inp.content, 500))}
`; + body += `
File: ${esc(inp.file_path || '')}
`; + if (inp.content) body += `
Content
${esc(truncate(inp.content, 500))}
`; } else if (name === 'Edit') { - html += `
File: ${esc(inp.file_path || '')}
`; - if (inp.old_string) html += `
${esc(truncate(inp.old_string, 300))}
`; - if (inp.new_string) html += `
${esc(truncate(inp.new_string, 300))}
`; + body += `
File: ${esc(inp.file_path || '')}
`; + if (inp.old_string) body += `
Old
${esc(truncate(inp.old_string, 300))}
`; + if (inp.new_string) body += `
New
${esc(truncate(inp.new_string, 300))}
`; } else if (name === 'Glob') { - html += `
Pattern: ${esc(inp.pattern || '')}
`; + body += `
Pattern: ${esc(inp.pattern || '')}${inp.path ? ' in ' + esc(inp.path) + '' : ''}
`; } else if (name === 'Grep') { - html += `
Pattern: ${esc(inp.pattern || '')}${inp.path ? ' in ' + esc(inp.path) + '' : ''}
`; + body += `
Pattern: ${esc(inp.pattern || '')}${inp.path ? ' in ' + esc(inp.path) + '' : ''}
`; } else if (name === 'Task') { - html += `
${esc(inp.subagent_type || '')} — ${esc(inp.description || '')}
`; + body += `
${esc(inp.subagent_type || '')} — ${esc(inp.description || '')}
`; + if (inp.prompt) body += `
Prompt
${esc(truncate(inp.prompt, 500))}
`; } else if (name === 'TodoWrite') { const todos = inp.todos || []; for (const t of todos) { const icon = {'completed': '[x]', 'in_progress': '[~]', 'pending': '[ ]'}[t.status] || '[ ]'; - html += `
${icon} ${esc(t.content || '')}
`; + body += `
${icon} ${esc(t.content || '')}
`; + } + } else if (name === 'AskUserQuestion') { + const questions = inp.questions || []; + for (const q of questions) { + body += `
Q: ${esc(q.question || '')}
`; } } else { const s = JSON.stringify(inp, null, 2); - html += `
${esc(truncate(s, 500))}
`; + body += `
${esc(truncate(s, 500))}
`; } - html += '
'; - return html; + return `
${esc(summary)}
${body}
`; +} + +function _toolResultHasBody(parsed) { + const rt = parsed.result_type || 'unknown'; + if (rt === 'bash') return !!(parsed.stdout || parsed.stderr); + if (rt === 'todo_write') return !!(parsed.todos && parsed.todos.length); + if (rt === 'user_input') return true; + if (rt === 'task' && (parsed.total_duration_ms || parsed.retrieval_status || parsed.description)) return true; + return false; +} + +function renderToolResult(parsed) { + const rt = parsed.result_type || 'unknown'; + let summary = ''; + let body = ''; + + if (rt === 'bash') { + const exitCode = parsed.exit_code; + const status = parsed.interrupted ? 'interrupted' : (parsed.is_error ? `error (exit ${exitCode})` : (exitCode === 0 ? 'success' : `exit ${exitCode}`)); + summary = `Bash Result (${status})`; + if (parsed.stdout) body += `
stdout
${esc(truncate(parsed.stdout, 2000))}
`; + if (parsed.stderr) body += `
stderr
${esc(truncate(parsed.stderr, 1000))}
`; + } else if (rt === 'file_read') { + const numLines = parsed.num_lines ? ` (${parsed.num_lines} lines)` : ''; + summary = `Read: ${parsed.file_path || ''}${numLines}`; + } else if (rt === 'file_edit') { + summary = `Edited: ${parsed.file_path || ''}`; + } else if (rt === 'file_write') { + summary = `Wrote: ${parsed.file_path || ''}`; + } else if (rt === 'glob') { + const trunc = parsed.truncated ? ' (truncated)' : ''; + summary = `Glob: ${parsed.num_files || 0} files found${trunc}`; + } else if (rt === 'grep') { + summary = `Grep: ${parsed.num_files || 0} files, ${parsed.num_lines || 0} lines`; + } else if (rt === 'web_search') { + summary = `Search: "${parsed.query || ''}" - ${parsed.result_count || 0} results`; + } else if (rt === 'web_fetch') { + summary = `Fetch: ${parsed.url || ''} (${parsed.status_code || '?'})`; + } else if (rt === 'task') { + const status = parsed.status || 'completed'; + const dur = parsed.total_duration_ms; + const durStr = dur ? ` (${(dur / 1000).toFixed(1)}s)` : ''; + const tokStr = parsed.total_tokens ? `, ${parsed.total_tokens.toLocaleString()} tokens` : ''; + const toolStr = parsed.total_tool_use_count ? `, ${parsed.total_tool_use_count} tool calls` : ''; + summary = `Task ${status}${durStr}${tokStr}${toolStr}`; + if (parsed.retrieval_status) summary = `Task retrieval: ${parsed.retrieval_status}`; + if (parsed.description) summary = `Task launched: ${parsed.description}`; + } else if (rt === 'todo_write') { + const count = parsed.todo_count || 0; + summary = `Todos updated (${count} items)`; + if (parsed.todos && parsed.todos.length) { + for (const t of parsed.todos) { + const icon = {'completed': '\u2705', 'in_progress': '\u23f3', 'pending': '\u2b1c'}[t.status] || '\u2b1c'; + body += `
${icon} ${esc(t.content || '')}
`; + } + } + } else if (rt === 'user_input') { + summary = 'User input received'; + const qs = parsed.questions || []; + const ans = parsed.answers || {}; + for (const q of qs) { + body += `
Q: ${esc(q.question || '')}
`; + } + const ansKeys = Object.keys(ans); + if (ansKeys.length) { + for (const k of ansKeys) { + body += `
A: ${esc(String(ans[k]))}
`; + } + } + } else if (rt === 'plan') { + summary = `Plan: ${parsed.file_path || ''}`; + } else { + summary = `Tool result (${rt})`; + } + + if (!body) { + return `
${esc(summary)}
`; + } + return `
${esc(summary)}
${body}
`; } // ==================== Search ==================== function showSearchPage() { + setHamburgerVisible(false); window.location.hash = '#search'; const content = document.getElementById('content'); content.innerHTML = `
- ← Back to Projects + ← Back

Search


@@ -352,7 +653,7 @@ async function doSearch() { if (!results.length) html += '
No results found.
'; html += '
'; - container.innerHTML = html; + smoothSet(container, html); } catch (e) { container.innerHTML = `
Error: ${esc(e.message)}
`; } @@ -360,24 +661,24 @@ async function doSearch() { // ==================== Export ==================== -async function bulkExport() { - if (!confirm('Export all sessions as a zip file?')) return; - const fname = `claude-code-export-${new Date().toISOString().slice(0, 10)}.zip`; - // Get file handle BEFORE any async work (must be in user gesture) - const handle = await getFileHandle(fname, [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }]); - if (!handle) return; - const btn = event.target.closest('button'); - if (btn) { btn.disabled = true; btn.textContent = 'Exporting...'; } - try { - const res = await fetch('/api/export', { method: 'POST' }); - if (!res.ok) throw new Error(`Export failed: ${res.status}`); - const blob = await res.blob(); - await writeToHandle(handle, blob, fname); - } catch (e) { - alert('Export failed: ' + e.message); - } finally { - if (btn) { btn.disabled = false; btn.textContent = 'Export all'; } - } +function bulkExport() { + showConfirm('Export all sessions as a zip file?', async () => { + const fname = `claude-code-export-${new Date().toISOString().slice(0, 10)}.zip`; + const handle = await getFileHandle(fname, [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }]); + if (!handle) return; + const btn = document.querySelector('.export-all-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'Exporting...'; } + try { + const res = await fetch('/api/export', { method: 'POST' }); + if (!res.ok) throw new Error(`Export failed: ${res.status}`); + const blob = await res.blob(); + await writeToHandle(handle, blob, fname); + } catch (e) { + showToast('Export failed: ' + e.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Export all'; } + } + }); } async function downloadSession(project, sessionId) { @@ -391,7 +692,7 @@ async function downloadSession(project, sessionId) { const blob = await res.blob(); await writeToHandle(handle, blob, fname); } catch (e) { - alert('Download failed: ' + e.message); + showToast('Download failed: ' + e.message, 'error'); } } @@ -427,7 +728,7 @@ function copyAll() { const msgs = document.querySelector('.messages-container'); if (!msgs) return; const text = msgs.innerText; - navigator.clipboard.writeText(text).then(() => alert('Copied to clipboard')); + navigator.clipboard.writeText(text).then(() => showToast('Copied to clipboard', 'success')); } // ==================== Theme ==================== @@ -486,7 +787,29 @@ function escContent(s) { } function formatTs(ts) { - try { return new Date(ts).toLocaleString(); } catch { return ts; } + try { + const d = new Date(ts); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const yyyy = d.getUTCFullYear(); + let hh = d.getUTCHours(); + const ampm = hh >= 12 ? 'PM' : 'AM'; + hh = hh % 12 || 12; + const hhStr = String(hh).padStart(2, '0'); + const min = String(d.getUTCMinutes()).padStart(2, '0'); + const ss = String(d.getUTCSeconds()).padStart(2, '0'); + return `${mm}/${dd}/${yyyy} ${hhStr}:${min}:${ss} ${ampm}`; + } catch { return ts; } +} + +function formatDate(ts) { + try { + const d = new Date(ts); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const yyyy = d.getUTCFullYear(); + return `${mm}/${dd}/${yyyy}`; + } catch { return ts ? ts.slice(0, 10) : ''; } } function formatSize(bytes) { diff --git a/utils/json_exporter.py b/utils/json_exporter.py new file mode 100644 index 0000000..84f6163 --- /dev/null +++ b/utils/json_exporter.py @@ -0,0 +1,45 @@ +"""JSON export format. Dumps everything -- no data loss compared to the raw +JSONL, but in a sane structure with computed stats included.""" + +import json +from datetime import datetime, timezone + + +def session_to_json(session: dict, stats: dict = None, indent: int = 2) -> str: + """Serialize a parsed session to a JSON string with schema versioning. + Pass indent=None if you want compact output for piping.""" + output = { + "schema_version": "2.0", + "exported_at": datetime.now(timezone.utc).isoformat(), + "session_id": session["session_id"], + "title": session["title"], + "metadata": _serialize_metadata(session["metadata"]), + "stats": stats, + "messages": _serialize_messages(session["messages"]), + } + return json.dumps(output, indent=indent, default=str, ensure_ascii=False) + + +def _serialize_metadata(meta: dict) -> dict: + """json.dumps chokes on sets, so convert them to sorted lists.""" + result = {} + for key, val in meta.items(): + if isinstance(val, set): + result[key] = sorted(val) + else: + result[key] = val + return result + + +def _serialize_messages(messages: list) -> list: + """Same set-to-list cleanup, but for each message dict.""" + out = [] + for msg in messages: + clean = {} + for key, val in msg.items(): + if isinstance(val, set): + clean[key] = sorted(val) + else: + clean[key] = val + out.append(clean) + return out diff --git a/utils/jsonl_parser.py b/utils/jsonl_parser.py index 8d8ccfe..f46e7ed 100644 --- a/utils/jsonl_parser.py +++ b/utils/jsonl_parser.py @@ -1,4 +1,5 @@ -"""Parse Claude Code JSONL session files into structured conversation data.""" +"""Reads Claude Code .jsonl session files and turns them into dicts we can +actually work with -- messages, tool calls, token counts, file activity, etc.""" import json import os @@ -6,11 +7,9 @@ def parse_session(filepath: str) -> dict: - """Parse a JSONL session file and return structured conversation data. - - Returns a dict with: - session_id, project, messages[], metadata{} - """ + """Main entry point. Reads every line from a .jsonl file and builds up + a session dict with messages, metadata (tokens, models, tool counts), + and file/command activity.""" session_id = os.path.basename(filepath).replace(".jsonl", "") messages = [] metadata = { @@ -29,6 +28,28 @@ def parse_session(filepath: str) -> dict: "git_branch": None, "permission_mode": None, "compactions": 0, + # Extended token accounting + "total_ephemeral_5m_tokens": 0, + "total_ephemeral_1h_tokens": 0, + "service_tiers": set(), + # Timing + "session_wall_time_seconds": None, + # Compaction details + "compact_boundaries": [], + # Error tracking + "api_errors": 0, + # File activity (from tool_use inputs) + "files_read": set(), + "files_written": set(), + "files_created": set(), + "bash_commands": [], + "web_fetches": [], + # Sidechain tracking + "sidechain_messages": 0, + # Stop reasons + "stop_reasons": {}, + # Entry type counts + "entry_counts": {}, } with open(filepath, "r", encoding="utf-8", errors="replace") as f: @@ -54,14 +75,45 @@ def parse_session(filepath: str) -> dict: metadata["first_timestamp"] = ts metadata["last_timestamp"] = ts + # Count entry types + if entry_type: + metadata["entry_counts"][entry_type] = ( + metadata["entry_counts"].get(entry_type, 0) + 1 + ) + + # Track sidechain + if entry.get("isSidechain"): + metadata["sidechain_messages"] += 1 + if entry_type == "user": _process_user(entry, messages, metadata) elif entry_type == "assistant": _process_assistant(entry, messages, metadata) elif entry_type == "system": _process_system(entry, messages, metadata) + elif entry_type == "progress": + _process_progress(entry, messages) metadata["models_used"] = sorted(metadata["models_used"]) + metadata["service_tiers"] = sorted(metadata["service_tiers"]) + metadata["files_read"] = sorted(metadata["files_read"]) + metadata["files_written"] = sorted(metadata["files_written"]) + metadata["files_created"] = sorted(metadata["files_created"]) + + # Compute wall clock time + if metadata["first_timestamp"] and metadata["last_timestamp"]: + try: + t0 = datetime.fromisoformat( + metadata["first_timestamp"].replace("Z", "+00:00") + ) + t1 = datetime.fromisoformat( + metadata["last_timestamp"].replace("Z", "+00:00") + ) + metadata["session_wall_time_seconds"] = max( + 0, (t1 - t0).total_seconds() + ) + except (ValueError, AttributeError): + pass title = _infer_title(messages) @@ -74,7 +126,8 @@ def parse_session(filepath: str) -> dict: def _process_user(entry: dict, messages: list, metadata: dict): - """Process a user-type entry.""" + """Pull out text, tool results, and session-level metadata (cwd, version, etc.) + from a user entry.""" if metadata["version"] is None: metadata["version"] = entry.get("version") if metadata["cwd"] is None: @@ -85,9 +138,20 @@ def _process_user(entry: dict, messages: list, metadata: dict): metadata["permission_mode"] = entry.get("permissionMode") msg = entry.get("message", {}) - text = _extract_text(msg.get("content", [])) + content = msg.get("content", []) + text = _extract_text(content) + images = _extract_images(content) tool_result = entry.get("toolUseResult") + tool_result_parsed = _parse_tool_result(tool_result, entry.get("slug")) + + # Also extract images from toolUseResult content (e.g., Read tool on image files) + if isinstance(tool_result, dict) and "content" in tool_result: + tr_content = tool_result["content"] + if isinstance(tr_content, list): + tr_images = _extract_images(tr_content) + if tr_images: + images = (images or []) + tr_images messages.append({ "role": "user", @@ -95,24 +159,55 @@ def _process_user(entry: dict, messages: list, metadata: dict): "parent_uuid": entry.get("parentUuid"), "timestamp": entry.get("timestamp"), "text": text, + "images": images if images else None, "is_sidechain": entry.get("isSidechain", False), "tool_result": tool_result, + "tool_result_parsed": tool_result_parsed, "slug": entry.get("slug"), }) def _process_assistant(entry: dict, messages: list, metadata: dict): - """Process an assistant-type entry.""" + """Handle assistant responses -- splits content into text, thinking blocks, + and tool_use calls, and accumulates token/model/tool stats.""" msg = entry.get("message", {}) model = msg.get("model", "") - if model: + if model and model != "": metadata["models_used"].add(model) + # API error tracking + if entry.get("isApiErrorMessage"): + metadata["api_errors"] += 1 + usage = msg.get("usage", {}) metadata["total_input_tokens"] += usage.get("input_tokens", 0) metadata["total_output_tokens"] += usage.get("output_tokens", 0) metadata["total_cache_read_tokens"] += usage.get("cache_read_input_tokens", 0) - metadata["total_cache_creation_tokens"] += usage.get("cache_creation_input_tokens", 0) + metadata["total_cache_creation_tokens"] += usage.get( + "cache_creation_input_tokens", 0 + ) + + # Extended cache metrics + cache_creation = usage.get("cache_creation", {}) + if isinstance(cache_creation, dict): + metadata["total_ephemeral_5m_tokens"] += cache_creation.get( + "ephemeral_5m_input_tokens", 0 + ) + metadata["total_ephemeral_1h_tokens"] += cache_creation.get( + "ephemeral_1h_input_tokens", 0 + ) + + # Service tier + tier = usage.get("service_tier") + if tier: + metadata["service_tiers"].add(tier) + + # Stop reason tracking + stop_reason = msg.get("stop_reason", "") + if stop_reason: + metadata["stop_reasons"][stop_reason] = ( + metadata["stop_reasons"].get(stop_reason, 0) + 1 + ) content_parts = _normalize_content(msg.get("content", [])) text_parts = [] @@ -127,6 +222,7 @@ def _process_assistant(entry: dict, messages: list, metadata: dict): thinking_parts.append(part.get("thinking", "")) elif ptype == "tool_use": tool_name = part.get("name", "unknown") + tool_input = part.get("input", {}) metadata["total_tool_calls"] += 1 metadata["tool_call_counts"][tool_name] = ( metadata["tool_call_counts"].get(tool_name, 0) + 1 @@ -134,8 +230,11 @@ def _process_assistant(entry: dict, messages: list, metadata: dict): tool_uses.append({ "id": part.get("id"), "name": tool_name, - "input": part.get("input", {}), + "input": tool_input, }) + # Track file activity from tool inputs + safe_input = tool_input if isinstance(tool_input, dict) else {} + _track_file_activity(tool_name, safe_input, metadata) messages.append({ "role": "assistant", @@ -143,25 +242,35 @@ def _process_assistant(entry: dict, messages: list, metadata: dict): "parent_uuid": entry.get("parentUuid"), "timestamp": entry.get("timestamp"), "model": model, - "stop_reason": msg.get("stop_reason"), + "stop_reason": stop_reason, "text": "\n".join(text_parts), "thinking": "\n\n".join(thinking_parts) if thinking_parts else None, "tool_uses": tool_uses if tool_uses else None, "is_sidechain": entry.get("isSidechain", False), + "is_api_error": entry.get("isApiErrorMessage", False), "usage": { "input_tokens": usage.get("input_tokens", 0), "output_tokens": usage.get("output_tokens", 0), "cache_read": usage.get("cache_read_input_tokens", 0), "cache_creation": usage.get("cache_creation_input_tokens", 0), + "service_tier": usage.get("service_tier"), }, }) def _process_system(entry: dict, messages: list, metadata: dict): - """Process a system-type entry.""" + """Handle system entries (mostly compact_boundary markers from context + compaction).""" subtype = entry.get("subtype", "") if subtype == "compact_boundary": metadata["compactions"] += 1 + compact_meta = entry.get("compactMetadata") + if isinstance(compact_meta, dict): + metadata["compact_boundaries"].append({ + "timestamp": entry.get("timestamp"), + "trigger": compact_meta.get("trigger"), + "pre_tokens": compact_meta.get("preTokens"), + }) messages.append({ "role": "system", @@ -174,8 +283,255 @@ def _process_system(entry: dict, messages: list, metadata: dict): }) +def _process_progress(entry: dict, messages: list): + """Capture progress entries -- streaming bash output, hook results, etc. + These are noisy so we mostly just store them for the JSON export.""" + data = entry.get("data", {}) + progress_type = data.get("type", "") + + messages.append({ + "role": "progress", + "uuid": entry.get("uuid"), + "parent_uuid": entry.get("parentUuid"), + "timestamp": entry.get("timestamp"), + "progress_type": progress_type, + "data": data, + "tool_use_id": entry.get("toolUseID"), + "parent_tool_use_id": entry.get("parentToolUseID"), + "is_sidechain": entry.get("isSidechain", False), + }) + + +def _track_file_activity(tool_name: str, tool_input: dict, metadata: dict): + """Look at what each tool call did and record which files got touched, + what commands got run, what URLs got fetched.""" + fp = tool_input.get("file_path", "") + if tool_name == "Read" and fp: + metadata["files_read"].add(fp) + elif tool_name == "Write" and fp: + metadata["files_created"].add(fp) + elif tool_name == "Edit" and fp: + metadata["files_written"].add(fp) + elif tool_name == "Bash": + cmd = tool_input.get("command", "") + if cmd: + metadata["bash_commands"].append(cmd) + elif tool_name in ("WebFetch", "WebSearch"): + url_or_query = tool_input.get("url") or tool_input.get("query", "") + if url_or_query: + metadata["web_fetches"].append(url_or_query) + + +def _parse_tool_result(tool_result, slug: str = None) -> dict | None: + """Figure out what kind of tool result this is (bash, file edit, glob, etc.) + by looking at which keys are present, since the JSONL doesn't always tag them.""" + if not isinstance(tool_result, dict): + return None + + result = {"slug": slug} + + # Bash results: have stdout/stderr/interrupted + if "stdout" in tool_result or "stderr" in tool_result: + result["result_type"] = "bash" + result["stdout"] = tool_result.get("stdout", "") + result["stderr"] = tool_result.get("stderr", "") + result["exit_code"] = tool_result.get("exitCode") + result["interrupted"] = tool_result.get("interrupted", False) + result["is_error"] = tool_result.get("is_error", False) + result["return_code_interpretation"] = tool_result.get( + "returnCodeInterpretation" + ) + return result + + # File edit results: have filePath + structuredPatch or oldString/newString + if "structuredPatch" in tool_result or ( + "filePath" in tool_result and "newString" in tool_result + ): + result["result_type"] = "file_edit" + result["file_path"] = tool_result.get("filePath", "") + result["replace_all"] = tool_result.get("replaceAll", False) + return result + + # File create/write results: have filePath + content but no patch + if "filePath" in tool_result and "content" in tool_result: + result["result_type"] = "file_write" + result["file_path"] = tool_result.get("filePath", "") + return result + + # Glob results: have filenames array + if "filenames" in tool_result and isinstance( + tool_result.get("filenames"), list + ): + result["result_type"] = "glob" + result["num_files"] = tool_result.get("numFiles", len(tool_result["filenames"])) + result["truncated"] = tool_result.get("truncated", False) + result["duration_ms"] = tool_result.get("durationMs") + return result + + # Grep results: have mode + numFiles/numLines + if "mode" in tool_result and "numFiles" in tool_result: + result["result_type"] = "grep" + result["mode"] = tool_result.get("mode") + result["num_files"] = tool_result.get("numFiles", 0) + result["num_lines"] = tool_result.get("numLines", 0) + result["duration_ms"] = tool_result.get("durationMs") + return result + + # Read result: have file dict with content + if "file" in tool_result and isinstance(tool_result["file"], dict): + result["result_type"] = "file_read" + result["file_path"] = tool_result["file"].get("filePath", "") + result["num_lines"] = tool_result["file"].get("numLines") + return result + + # WebSearch results + if "query" in tool_result and "results" in tool_result: + result["result_type"] = "web_search" + result["query"] = tool_result.get("query", "") + result["result_count"] = len(tool_result.get("results", [])) + result["duration_seconds"] = tool_result.get("durationSeconds") + return result + + # WebFetch results + if "url" in tool_result and "code" in tool_result: + result["result_type"] = "web_fetch" + result["url"] = tool_result.get("url", "") + result["status_code"] = tool_result.get("code") + result["duration_ms"] = tool_result.get("durationMs") + return result + + # Task results -- multiple variants + if "task_id" in tool_result or "message" in tool_result: + result["result_type"] = "task" + result["task_id"] = tool_result.get("task_id") + result["task_type"] = tool_result.get("task_type") + return result + + # Task retrieval (has nested "task" dict + retrieval_status) + if "retrieval_status" in tool_result and "task" in tool_result: + result["result_type"] = "task" + task_obj = tool_result["task"] if isinstance(tool_result["task"], dict) else {} + result["retrieval_status"] = tool_result.get("retrieval_status") + result["task_id"] = task_obj.get("task_id") + return result + + # Task completed subagent (has agentId + totalDurationMs + status) + if "agentId" in tool_result and "totalDurationMs" in tool_result: + result["result_type"] = "task" + result["agent_id"] = tool_result.get("agentId") + result["status"] = tool_result.get("status") + result["total_duration_ms"] = tool_result.get("totalDurationMs") + result["total_tokens"] = tool_result.get("totalTokens") + result["total_tool_use_count"] = tool_result.get("totalToolUseCount") + return result + + # Task async launched (has agentId + isAsync + status) + if "agentId" in tool_result and "isAsync" in tool_result: + result["result_type"] = "task" + result["agent_id"] = tool_result.get("agentId") + result["status"] = tool_result.get("status") + result["description"] = tool_result.get("description") + return result + + # TodoWrite results (has newTodos/oldTodos) + if "newTodos" in tool_result or "oldTodos" in tool_result: + result["result_type"] = "todo_write" + new_todos = tool_result.get("newTodos", []) + result["todo_count"] = len(new_todos) if isinstance(new_todos, list) else 0 + result["todos"] = new_todos if isinstance(new_todos, list) else [] + return result + + # AskUserQuestion results (has questions/answers) + if "questions" in tool_result and "answers" in tool_result: + result["result_type"] = "user_input" + result["questions"] = tool_result.get("questions", []) + result["answers"] = tool_result.get("answers", {}) + return result + + # Plan results (has plan + filePath) + if "plan" in tool_result and "filePath" in tool_result: + result["result_type"] = "plan" + result["file_path"] = tool_result.get("filePath", "") + return result + + # Generic fallback + result["result_type"] = "unknown" + return result + + +def quick_session_info(filepath: str) -> dict: + """Lightweight peek at a session file -- returns title and last_timestamp + without fully parsing all messages. Much faster than parse_session() for + large files. + + Strategy: read the first ~50 lines for the title, then seek to the end of + the file and read the last chunk to find the last timestamp.""" + title = None + first_ts = None + last_ts = None + + # --- Pass 1: read first lines to find the title and first_timestamp --- + with open(filepath, "r", encoding="utf-8", errors="replace") as f: + lines_read = 0 + for line in f: + lines_read += 1 + if lines_read > 80: + break + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + + ts = entry.get("timestamp") + if ts: + if first_ts is None: + first_ts = ts + last_ts = ts # keep updating in case file is small + + if title is None and entry.get("type") == "user": + msg = entry.get("message", {}) + text = _extract_text(msg.get("content", [])) + if text: + clean = _strip_system_tags(text).strip() + first_line = clean.split("\n")[0][:100] + if first_line: + title = first_line + + # --- Pass 2: read last chunk for the last timestamp --- + file_size = os.path.getsize(filepath) + if file_size > 10000: + # Only bother with tail-read for non-tiny files + chunk_size = min(file_size, 32768) + with open(filepath, "rb") as f: + f.seek(file_size - chunk_size) + tail = f.read().decode("utf-8", errors="replace") + # Parse lines in reverse to find latest timestamp + for line in reversed(tail.splitlines()): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + ts = entry.get("timestamp") + if ts: + last_ts = ts + break + + return { + "title": title or "Untitled Session", + "first_timestamp": first_ts, + "last_timestamp": last_ts, + } + + def _normalize_content(content) -> list: - """Normalize content to a list of dicts. Handles string or list formats.""" + """Content can be a plain string, a list of strings, or a list of typed + blocks. Normalize everything into [{type, text}, ...] form.""" if isinstance(content, str): return [{"type": "text", "text": content}] if isinstance(content, list): @@ -190,7 +546,7 @@ def _normalize_content(content) -> list: def _extract_text(content_parts) -> str: - """Extract plain text from message content parts.""" + """Grab just the text blocks out of a content array, ignore tool_use/thinking.""" parts = _normalize_content(content_parts) texts = [] for part in parts: @@ -199,9 +555,35 @@ def _extract_text(content_parts) -> str: return "\n".join(texts) +def _extract_images(content_parts) -> list: + """Pull base64 image blocks out of a content array. + Also looks inside nested tool_result content blocks.""" + parts = _normalize_content(content_parts) + images = [] + for part in parts: + if part.get("type") == "image": + source = part.get("source", {}) + if source.get("type") == "base64" and source.get("data"): + images.append({ + "media_type": source.get("media_type", "image/png"), + "data": source["data"], + }) + elif part.get("type") == "tool_result": + nested = part.get("content", []) + if isinstance(nested, list): + for sub in nested: + if isinstance(sub, dict) and sub.get("type") == "image": + source = sub.get("source", {}) + if source.get("type") == "base64" and source.get("data"): + images.append({ + "media_type": source.get("media_type", "image/png"), + "data": source["data"], + }) + return images + + def _infer_title(messages: list) -> str: - """Infer a session title from the first user message.""" - import re + """Use the first line of the first real user message as the session title.""" for msg in messages: if msg["role"] == "user" and msg.get("text"): text = _strip_system_tags(msg["text"]).strip() @@ -212,7 +594,8 @@ def _infer_title(messages: list) -> str: def _strip_system_tags(text: str) -> str: - """Remove Claude Code internal XML tags from text.""" + """Strip out the internal XML tags Claude Code injects (system-reminder, + ide_opened_file, etc.) so exported text is clean.""" import re # Remove block tags and their content for tag in ( diff --git a/utils/md_exporter.py b/utils/md_exporter.py index 2fc0109..1cc3ef5 100644 --- a/utils/md_exporter.py +++ b/utils/md_exporter.py @@ -1,19 +1,21 @@ -"""Export a parsed Claude Code session to Markdown with YAML frontmatter.""" +"""Markdown export. Produces a .md with YAML frontmatter, a summary section +(cost, files touched, commands run), and the full conversation.""" from datetime import datetime -def session_to_markdown(session: dict) -> str: - """Convert a parsed session to rich Markdown with YAML frontmatter.""" - meta = session["metadata"] - messages = session["messages"] - title = session["title"] - +def session_to_markdown(session: dict, stats: dict = None) -> str: + """Glue together frontmatter + header + summary + conversation body.""" frontmatter = _build_frontmatter(session) header = _build_header(session) - body = _build_body(messages) + summary = _build_summary(session, stats) if stats else "" + body = _build_body(session["messages"]) - return f"{frontmatter}\n{header}\n{body}" + parts = [frontmatter, header] + if summary: + parts.append(summary) + parts.append(body) + return "\n".join(parts) def _build_frontmatter(session: dict) -> str: @@ -37,6 +39,12 @@ def _build_frontmatter(session: dict) -> str: meta["tool_call_counts"].items(), key=lambda x: -x[1] ): lines.append(f" {tool}: {count}") + if meta.get("stop_reasons"): + lines.append("stop_reasons:") + for reason, count in sorted( + meta["stop_reasons"].items(), key=lambda x: -x[1] + ): + lines.append(f" {reason}: {count}") if meta["cwd"]: lines.append(f"working_directory: \"{_escape_yaml(meta['cwd'])}\"") if meta["git_branch"]: @@ -45,9 +53,29 @@ def _build_frontmatter(session: dict) -> str: lines.append(f"claude_code_version: {meta['version']}") if meta["permission_mode"]: lines.append(f"permission_mode: {meta['permission_mode']}") + if meta.get("service_tiers"): + lines.append(f"service_tiers: {', '.join(meta['service_tiers'])}") lines.append(f"message_count: {len(session['messages'])}") if meta["compactions"] > 0: lines.append(f"compactions: {meta['compactions']}") + if meta.get("api_errors", 0) > 0: + lines.append(f"api_errors: {meta['api_errors']}") + if meta.get("sidechain_messages", 0) > 0: + lines.append(f"sidechain_messages: {meta['sidechain_messages']}") + wall = meta.get("session_wall_time_seconds") + if wall is not None: + lines.append(f"wall_clock_seconds: {int(wall)}") + files_r = meta.get("files_read", []) + files_w = meta.get("files_written", []) + files_c = meta.get("files_created", []) + if files_r or files_w or files_c: + lines.append(f"files_read: {len(files_r)}") + lines.append(f"files_written: {len(files_w)}") + lines.append(f"files_created: {len(files_c)}") + if meta.get("bash_commands"): + lines.append(f"commands_run: {len(meta['bash_commands'])}") + if meta.get("web_fetches"): + lines.append(f"web_fetches: {len(meta['web_fetches'])}") lines.append("---") return "\n".join(lines) @@ -68,6 +96,12 @@ def _build_header(session: dict) -> str: parts.append(f"Tokens: {token_total:,}") if meta["total_tool_calls"] > 0: parts.append(f"Tool calls: {meta['total_tool_calls']}") + wall = meta.get("session_wall_time_seconds") + if wall is not None: + from utils.session_stats import _format_duration + dur = _format_duration(wall) + if dur: + parts.append(f"Duration: {dur}") if parts: lines.append(f"_{' | '.join(parts)}_\n") @@ -75,6 +109,78 @@ def _build_header(session: dict) -> str: return "\n".join(lines) +def _build_summary(session: dict, stats: dict) -> str: + """The summary block that goes right after the header -- cost, files + table, command list, URLs, tool result breakdown.""" + lines = ["## Session Summary\n"] + + # Cost estimate + cost = stats.get("cost_estimate_usd") + if cost is not None: + lines.append(f"**Estimated cost:** ~${cost:.2f} USD\n") + + # Files touched + ft = stats.get("files_touched", {}) + read_files = ft.get("read", []) + written_files = ft.get("written", []) + created_files = ft.get("created", []) + if read_files or written_files or created_files: + lines.append("### Files Touched\n") + lines.append("| Action | File |") + lines.append("|--------|------|") + for fp in created_files: + lines.append(f"| Create | `{_truncate(fp, 100)}` |") + for fp in written_files: + lines.append(f"| Edit | `{_truncate(fp, 100)}` |") + for fp in read_files[:20]: + lines.append(f"| Read | `{_truncate(fp, 100)}` |") + if len(read_files) > 20: + lines.append(f"| Read | _...and {len(read_files) - 20} more_ |") + lines.append("") + + # Commands run + commands = stats.get("commands_run", []) + if commands: + lines.append("### Commands Run\n") + for i, cmd in enumerate(commands[:30], 1): + status = "" + if cmd.get("is_error"): + status = " -- **error**" + elif cmd.get("interrupted"): + status = " -- interrupted" + elif cmd.get("exit_code") == 0: + status = " -- success" + elif cmd.get("return_code_interpretation"): + status = f" -- {cmd['return_code_interpretation']}" + lines.append(f"{i}. `{_truncate(cmd['command'], 120)}`{status}") + if len(commands) > 30: + lines.append(f"\n_...and {len(commands) - 30} more commands_") + lines.append("") + + # URLs accessed + urls = stats.get("urls_accessed", []) + if urls: + lines.append("### URLs Accessed\n") + for url in urls[:15]: + lines.append(f"- `{_truncate(url, 150)}`") + if len(urls) > 15: + lines.append(f"- _...and {len(urls) - 15} more_") + lines.append("") + + # Tool result summary + trs = stats.get("tool_result_summary", {}) + non_zero = {k: v for k, v in trs.items() if v > 0} + if non_zero: + lines.append("### Tool Results\n") + for k, v in non_zero.items(): + label = k.replace("_", " ").title() + lines.append(f"- {label}: {v}") + lines.append("") + + lines.append("---\n") + return "\n".join(lines) + + def _build_body(messages: list) -> str: parts = [] for msg in messages: @@ -85,6 +191,7 @@ def _build_body(messages: list) -> str: parts.append(_render_assistant(msg)) elif role == "system": parts.append(_render_system(msg)) + # Skip progress messages in MD (too noisy) return "\n".join(parts) @@ -98,11 +205,19 @@ def _render_user(msg: dict) -> str: if msg.get("slug"): lines.append(f"_Tool response: {msg['slug']}_\n") + if msg.get("images"): + for img in msg["images"]: + lines.append(f'User image\n') + if msg.get("text"): from utils.jsonl_parser import _strip_system_tags lines.append(_strip_system_tags(msg["text"])) - if msg.get("tool_result"): + # Render structured tool result instead of raw dump + trp = msg.get("tool_result_parsed") + if trp: + lines.append(_render_tool_result(trp)) + elif msg.get("tool_result"): tr = msg["tool_result"] if isinstance(tr, dict): lines.append("\n**Tool Result:**") @@ -124,11 +239,16 @@ def _render_assistant(msg: dict) -> str: meta_parts.append(f"In: {usage['input_tokens']:,}") if usage.get("output_tokens"): meta_parts.append(f"Out: {usage['output_tokens']:,}") + if usage.get("service_tier"): + meta_parts.append(f"Tier: {usage['service_tier']}") if msg.get("timestamp"): meta_parts.append(_format_ts(msg["timestamp"])) if meta_parts: lines.append(f"_{' | '.join(meta_parts)}_\n") + if msg.get("is_api_error"): + lines.append("**[API Error]**\n") + if msg.get("thinking"): lines.append("
Thinking\n") lines.append(msg["thinking"]) @@ -184,6 +304,8 @@ def _render_tool_use(tool: dict) -> str: elif name == "Task": lines.append(f">\n> Description: {inp.get('description', '')}") lines.append(f"> Agent: {inp.get('subagent_type', '')}") + if inp.get("prompt"): + lines.append(f">\n> **Prompt:**\n> ```\n> {_truncate(inp['prompt'], 500)}\n> ```") elif name == "TodoWrite": todos = inp.get("todos", []) for t in todos: @@ -205,6 +327,99 @@ def _render_tool_use(tool: dict) -> str: return "\n".join(lines) +def _render_tool_result(parsed: dict) -> str: + """Format a tool result nicely instead of dumping raw JSON.""" + rt = parsed.get("result_type", "unknown") + lines = [] + + if rt == "bash": + stdout = parsed.get("stdout", "") + stderr = parsed.get("stderr", "") + exit_code = parsed.get("exit_code") + status = "" + if parsed.get("interrupted"): + status = " (interrupted)" + elif parsed.get("is_error"): + status = f" (error, exit {exit_code})" + elif exit_code is not None: + status = f" (exit {exit_code})" + + lines.append(f"\n**Bash Result{status}:**") + if stdout: + lines.append(f"```\n{_truncate(stdout, 2000)}\n```") + if stderr: + lines.append(f"**stderr:**\n```\n{_truncate(stderr, 1000)}\n```") + + elif rt == "file_read": + fp = parsed.get("file_path", "") + num_lines = parsed.get("num_lines") + detail = f" ({num_lines} lines)" if num_lines else "" + lines.append(f"\n**Read:** `{fp}`{detail}") + + elif rt == "file_edit": + fp = parsed.get("file_path", "") + lines.append(f"\n**Edited:** `{fp}`") + + elif rt == "file_write": + fp = parsed.get("file_path", "") + lines.append(f"\n**Wrote:** `{fp}`") + + elif rt == "glob": + n = parsed.get("num_files", 0) + trunc = " (truncated)" if parsed.get("truncated") else "" + lines.append(f"\n**Glob:** {n} files found{trunc}") + + elif rt == "grep": + n = parsed.get("num_files", 0) + nl = parsed.get("num_lines", 0) + lines.append(f"\n**Grep:** {n} files, {nl} lines matched") + + elif rt == "web_search": + q = parsed.get("query", "") + rc = parsed.get("result_count", 0) + lines.append(f"\n**Search:** `{q}` -- {rc} results") + + elif rt == "web_fetch": + url = parsed.get("url", "") + code = parsed.get("status_code", "") + lines.append(f"\n**Fetch:** `{url}` -- status {code}") + + elif rt == "task": + status = parsed.get("status", "completed") + dur = parsed.get("total_duration_ms") + dur_str = f" ({dur / 1000:.1f}s)" if dur else "" + tok_str = f", {parsed['total_tokens']:,} tokens" if parsed.get("total_tokens") else "" + tool_str = f", {parsed['total_tool_use_count']} tool calls" if parsed.get("total_tool_use_count") else "" + if parsed.get("retrieval_status"): + lines.append(f"\n**Task retrieval:** {parsed['retrieval_status']}") + elif parsed.get("description"): + lines.append(f"\n**Task launched:** {parsed['description']}") + else: + lines.append(f"\n**Task {status}{dur_str}{tok_str}{tool_str}**") + + elif rt == "todo_write": + count = parsed.get("todo_count", 0) + lines.append(f"\n**Todos updated ({count} items):**") + for t in parsed.get("todos", []): + icon = {"completed": "[x]", "in_progress": "[~]", "pending": "[ ]"}.get( + t.get("status", ""), "[ ]" + ) + lines.append(f"- {icon} {t.get('content', '')}") + + elif rt == "user_input": + lines.append("\n**User input received:**") + for q in parsed.get("questions", []): + lines.append(f"- Q: {q.get('question', '')}") + for k, v in parsed.get("answers", {}).items(): + lines.append(f"- A: {v}") + + elif rt == "plan": + fp = parsed.get("file_path", "") + lines.append(f"\n**Plan:** `{fp}`") + + return "\n".join(lines) + + def _render_system(msg: dict) -> str: lines = [] subtype = msg.get("subtype", "") @@ -219,7 +434,7 @@ def _render_system(msg: dict) -> str: def _format_ts(ts: str) -> str: - """Format ISO timestamp to readable form.""" + """2024-01-15T10:30:00Z -> 2024-01-15 10:30:00""" try: dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) return dt.strftime("%Y-%m-%d %H:%M:%S") diff --git a/utils/session_path.py b/utils/session_path.py index b7427dc..96ea4cf 100644 --- a/utils/session_path.py +++ b/utils/session_path.py @@ -1,11 +1,22 @@ -"""OS-aware detection of Claude Code session storage path.""" +"""Finds where Claude Code stores its .jsonl session files on disk and +lists projects/sessions from that directory.""" import os import platform +def safe_join(base: str, *parts: str) -> str: + """Join path components and verify the result stays under base. + Raises ValueError if the resolved path escapes the base directory.""" + joined = os.path.realpath(os.path.join(base, *parts)) + base_resolved = os.path.realpath(base) + if not joined.startswith(base_resolved + os.sep) and joined != base_resolved: + raise ValueError(f"Path escapes base directory: {joined}") + return joined + + def get_claude_projects_dir() -> str: - """Return the path to ~/.claude/projects/ for the current OS.""" + """~/.claude/projects/ -- handles Windows USERPROFILE vs Unix HOME.""" system = platform.system() if system == "Windows": home = os.environ.get("USERPROFILE", os.path.expanduser("~")) @@ -15,7 +26,7 @@ def get_claude_projects_dir() -> str: def list_projects(base_dir: str | None = None) -> list[dict]: - """List all projects that have JSONL session files.""" + """Scan the projects dir and return info for each one that has .jsonl files.""" base = base_dir or get_claude_projects_dir() if not os.path.isdir(base): return [] @@ -59,7 +70,8 @@ def list_projects(base_dir: str | None = None) -> list[dict]: def _get_display_name(jsonl_path: str, fallback: str) -> str: - """Read the cwd from the first user entry in a JSONL file to get the real path.""" + """Peek at the first entry's cwd field to get a human-readable project path + instead of the hashed directory name.""" import json try: with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f: @@ -72,14 +84,16 @@ def _get_display_name(jsonl_path: str, fallback: str) -> str: if cwd: # Normalize: replace backslashes, strip trailing slash cwd = cwd.replace("\\", "/").rstrip("/") - return cwd + # Extract last folder name and capitalize first letter + folder = cwd.rsplit("/", 1)[-1] + return folder[:1].upper() + folder[1:] if folder else cwd except Exception: pass return fallback def list_sessions(project_dir: str) -> list[dict]: - """List all JSONL session files in a project directory.""" + """Return id, path, size, mtime for each .jsonl file in a project dir.""" sessions = [] if not os.path.isdir(project_dir): return sessions diff --git a/utils/session_stats.py b/utils/session_stats.py new file mode 100644 index 0000000..dffb5e3 --- /dev/null +++ b/utils/session_stats.py @@ -0,0 +1,208 @@ +"""Takes a parsed session and crunches the numbers -- cost estimates, file +activity, command success rates, conversation turns, etc. Bridges the raw +parser output to the exporters.""" + +# Approximate pricing per 1M tokens (USD) as of early 2026. +# Used for best-effort cost estimation only. +_MODEL_PRICING = { + # model_substring: (input_per_1m, output_per_1m) + "opus": (15.0, 75.0), + "sonnet": (3.0, 15.0), + "haiku": (0.25, 1.25), +} + + +def compute_stats(session: dict) -> dict: + """Build the full stats dict for a session. Everything the exporters and + API endpoints need -- file lists, command history, cost, turn count.""" + meta = session["metadata"] + messages = session["messages"] + + stats = { + "files_touched": _compute_files_touched(meta), + "commands_run": _compute_commands_run(messages), + "urls_accessed": list(meta.get("web_fetches", [])), + "conversation_turns": _count_turns(messages), + "wall_clock_seconds": meta.get("session_wall_time_seconds"), + "wall_clock_display": _format_duration( + meta.get("session_wall_time_seconds") + ), + "cost_estimate_usd": _estimate_cost(messages, meta), + "tool_result_summary": _summarize_tool_results(messages), + "stop_reason_summary": dict(meta.get("stop_reasons", {})), + "entry_type_counts": dict(meta.get("entry_counts", {})), + "sidechain_message_count": meta.get("sidechain_messages", 0), + "api_error_count": meta.get("api_errors", 0), + "compaction_events": meta.get("compact_boundaries", []), + } + return stats + + +def _compute_files_touched(meta: dict) -> dict: + """Split files into read-only, edited, and newly created buckets. Files + that were both read and edited only show up under edited.""" + read = set(meta.get("files_read", [])) + written = set(meta.get("files_written", [])) + created = set(meta.get("files_created", [])) + # Files that were written may also have been read + return { + "read": sorted(read - written - created), + "written": sorted(written), + "created": sorted(created), + "total_unique": len(read | written | created), + } + + +def _compute_commands_run(messages: list) -> list: + """Walk through messages and match up Bash tool_use calls with their + subsequent tool_result entries to get exit codes and error status.""" + commands = [] + # Build a map of tool_use_id -> command from assistant messages + pending_commands = {} + for msg in messages: + if msg["role"] == "assistant" and msg.get("tool_uses"): + for tu in msg["tool_uses"]: + if tu["name"] == "Bash": + cmd = tu["input"].get("command", "") + if cmd: + pending_commands[tu["id"]] = { + "command": cmd, + "timestamp": msg.get("timestamp"), + } + + # Match tool results back to commands + if msg["role"] == "user" and msg.get("tool_result_parsed"): + trp = msg["tool_result_parsed"] + if trp.get("result_type") == "bash": + # Try to find matching command by sequential order + if pending_commands: + first_id = next(iter(pending_commands)) + entry = pending_commands.pop(first_id) + entry["exit_code"] = trp.get("exit_code") + entry["is_error"] = trp.get("is_error", False) + entry["interrupted"] = trp.get("interrupted", False) + entry["return_code_interpretation"] = trp.get( + "return_code_interpretation" + ) + commands.append(entry) + + # Add any unmatched commands (no result captured) + for entry in pending_commands.values(): + entry["exit_code"] = None + entry["is_error"] = None + commands.append(entry) + + return commands + + +def _count_turns(messages: list) -> int: + """Count how many times the user said something and got a reply back.""" + turns = 0 + prev_role = None + for msg in messages: + role = msg["role"] + if role == "assistant" and prev_role == "user": + turns += 1 + if role in ("user", "assistant"): + prev_role = role + return turns + + +def _estimate_cost(messages: list, meta: dict) -> float | None: + """Rough cost estimate based on each message's token count and the model + that generated it. Not exact -- doesn't account for caching discounts.""" + total = 0.0 + has_data = False + + for msg in messages: + if msg["role"] != "assistant": + continue + model = msg.get("model", "") + usage = msg.get("usage", {}) + inp = usage.get("input_tokens", 0) + out = usage.get("output_tokens", 0) + if not (inp or out): + continue + + pricing = _get_pricing(model) + if pricing: + has_data = True + total += (inp / 1_000_000) * pricing[0] + total += (out / 1_000_000) * pricing[1] + + return round(total, 4) if has_data else None + + +def _get_pricing(model: str) -> tuple | None: + """Find pricing by checking if 'opus', 'sonnet', or 'haiku' appears in + the model name. Returns None for unknown models.""" + model_lower = model.lower() + for key, pricing in _MODEL_PRICING.items(): + if key in model_lower: + return pricing + return None + + +def _summarize_tool_results(messages: list) -> dict: + """Count up how many tool results succeeded, failed, or got interrupted, + broken down by tool type.""" + summary = { + "bash_success": 0, + "bash_error": 0, + "bash_interrupted": 0, + "file_reads": 0, + "file_edits": 0, + "file_writes": 0, + "glob_searches": 0, + "grep_searches": 0, + "web_fetches": 0, + "web_searches": 0, + "tasks": 0, + } + for msg in messages: + if msg["role"] != "user": + continue + trp = msg.get("tool_result_parsed") + if not trp: + continue + rt = trp.get("result_type", "") + if rt == "bash": + if trp.get("interrupted"): + summary["bash_interrupted"] += 1 + elif trp.get("is_error"): + summary["bash_error"] += 1 + else: + summary["bash_success"] += 1 + elif rt == "file_read": + summary["file_reads"] += 1 + elif rt == "file_edit": + summary["file_edits"] += 1 + elif rt == "file_write": + summary["file_writes"] += 1 + elif rt == "glob": + summary["glob_searches"] += 1 + elif rt == "grep": + summary["grep_searches"] += 1 + elif rt == "web_fetch": + summary["web_fetches"] += 1 + elif rt == "web_search": + summary["web_searches"] += 1 + elif rt == "task": + summary["tasks"] += 1 + return summary + + +def _format_duration(seconds) -> str | None: + """Turn seconds into something like '2h 15m' or '45s'.""" + if seconds is None: + return None + seconds = int(seconds) + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + secs = seconds % 60 + if minutes < 60: + return f"{minutes}m {secs}s" + hours = minutes // 60 + mins = minutes % 60 + return f"{hours}h {mins}m"