Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 64 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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/
```

Expand All @@ -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 `<details>` 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
53 changes: 44 additions & 9 deletions api/export_api.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand All @@ -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"])
Expand All @@ -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)
Comment thread
timon0305 marked this conversation as resolved.
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")
Expand All @@ -49,20 +67,37 @@ def bulk_export():


@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
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:
Comment thread
timon0305 marked this conversation as resolved.
return jsonify({"error": "Invalid path"}), 400

if not os.path.isfile(filepath):
return jsonify({"error": "Session not found"}), 404
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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",
Expand Down
41 changes: 35 additions & 6 deletions api/projects.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand All @@ -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/<path:project_name>/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
Expand All @@ -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)
2 changes: 1 addition & 1 deletion api/search.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""API endpoint for full-text search across all sessions."""
"""Search endpoint. Brute-force substring match across all sessions."""

import os

Expand Down
47 changes: 41 additions & 6 deletions api/sessions.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,57 @@
"""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__)


@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>")
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/<path:project_name>/<session_id>/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
6 changes: 3 additions & 3 deletions app.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Loading