Skip to content

Commit a8481b9

Browse files
authored
Merge pull request #6 from CppDigest/feature/session-browser
Add CLI + Web GUI for browsing Claude Code sessions
2 parents f705059 + 47e3ca6 commit a8481b9

15 files changed

Lines changed: 2905 additions & 378 deletions

README.md

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,65 @@ Browse and export Claude Code chat history — Web GUI and CLI.
44

55
## Features
66

7-
- **Web GUI**: Flask-based browser with project list, session viewer, full-text search, syntax highlighting, tool call rendering, thinking blocks, dark/light mode
8-
- **CLI Export**: Standalone script to export all sessions to Markdown with YAML frontmatter
9-
- **Rich Markdown**: Includes token usage, tool calls (Bash, Read, Edit, Write, Glob, Grep, Task, etc.), thinking blocks, model info, timestamps
10-
- **Incremental Export**: `--since last` flag to only export new/updated sessions
11-
- **Bulk Export**: Download all sessions as a zip from the web UI
7+
### Web GUI
8+
- **Project dashboard** with card grid, aggregate stats, and staggered animations
9+
- **Session viewer** with split layout (sidebar + message panel)
10+
- **Full-text search** across all sessions
11+
- **Syntax highlighting** for code blocks
12+
- **Tool call rendering** — Bash, Read, Edit, Write, Glob, Grep, Task, TodoWrite, WebFetch, WebSearch, and more
13+
- **Thinking blocks** — collapsible sections for Claude's reasoning
14+
- **Dark/light theme** with Inter font
15+
- **Responsive design** — mobile-friendly with hamburger sidebar
16+
- **Toast notifications** with icon, progress bar, and close button
17+
- **Confirm modals** with keyboard support (Enter/Escape) and backdrop blur
18+
- **Top loading bar** (YouTube-style) during data fetches
19+
- **Smooth transitions** — staggered card/message animations, crossfade content swaps
20+
- **Scroll-to-top button** in bottom-right corner
21+
- **Per-model badges** in session header
22+
- **Bulk export** — download all sessions as a zip
23+
24+
### CLI Export
25+
- Standalone script to export all sessions to Markdown with YAML frontmatter
26+
- Rich Markdown: token usage, tool calls, thinking blocks, model info, timestamps
27+
- `--since last` flag for incremental export (only new/updated sessions)
28+
- `--project` flag to export a specific project
1229

1330
## Quick Start
1431

1532
### Web GUI
1633

1734
```bash
18-
pip install flask
35+
# Create virtual environment
36+
python -m venv venv
37+
38+
# Activate (Windows)
39+
venv\Scripts\activate
40+
41+
# Activate (macOS/Linux)
42+
source venv/bin/activate
43+
44+
# Install dependencies
45+
pip install -r requirements.txt
46+
47+
# Run
1948
python app.py
20-
# Open http://localhost:3000
49+
# Open http://localhost:5000
50+
```
51+
52+
Options:
53+
```bash
54+
python app.py --port 8080 --host 0.0.0.0
55+
python app.py --base-dir /path/to/claude/projects
2156
```
2257

2358
### CLI Export
2459

2560
```bash
61+
# Activate venv first (see above), then:
62+
63+
# List all projects (shows directory names you can use with --project)
64+
python scripts/export.py list
65+
2666
# Export all sessions as zip
2767
python scripts/export.py
2868

@@ -32,10 +72,12 @@ python scripts/export.py --out ./exports --no-zip
3272
# Incremental export (only new sessions since last run)
3373
python scripts/export.py --since last
3474

35-
# Export specific project only
36-
python scripts/export.py --project myproject
75+
# Export specific project only (substring match on directory name)
76+
python scripts/export.py --project boost-capy
3777
```
3878

79+
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.
80+
3981
## Data Source
4082

4183
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
4688

4789
```
4890
claude-code-chat-browser/
49-
├── app.py # Flask entry point
91+
├── app.py # Flask entry point (default port 5000)
5092
├── api/
51-
│ ├── projects.py # Project listing endpoints
52-
│ ├── sessions.py # Session viewer endpoints
53-
│ ├── search.py # Full-text search
54-
│ └── export_api.py # Bulk and per-session export
93+
│ ├── projects.py # Project listing & session counts
94+
│ ├── sessions.py # Session parsing & message delivery
95+
│ ├── search.py # Full-text search across sessions
96+
│ └── export_api.py # Bulk zip and per-session Markdown export
5597
├── utils/
56-
│ ├── session_path.py # OS-aware path detection
57-
│ ├── jsonl_parser.py # JSONL session parser
58-
│ └── md_exporter.py # Markdown exporter with frontmatter
98+
│ ├── session_path.py # OS-aware path detection & project naming
99+
│ ├── jsonl_parser.py # JSONL session parser with tool result classification
100+
│ └── md_exporter.py # Markdown exporter with YAML frontmatter
59101
├── scripts/
60-
│ └── export.py # Standalone CLI export
102+
│ └── export.py # Standalone CLI export tool
61103
├── static/
62-
│ ├── index.html # SPA entry point
63-
│ ├── css/style.css # Dark/light theme
64-
│ └── js/app.js # Client-side routing and rendering
104+
│ ├── index.html # SPA entry point (Inter font, minimal markup)
105+
│ ├── css/style.css # Dark/light theme, responsive, animations
106+
│ └── js/app.js # Hash-based routing, rendering, UI components
65107
└── tests/
66108
```
67109

@@ -72,5 +114,5 @@ Each exported session includes:
72114
- **YAML frontmatter**: title, timestamps, session_id, models, token counts, tool call breakdown, working directory, git branch, Claude Code version
73115
- **Per-message metadata**: role, model, token usage (in/out/cache), timestamp
74116
- **Thinking blocks**: Collapsible `<details>` sections
75-
- **Tool calls**: Formatted by type (Bash commands, file reads/edits, glob/grep patterns, subagent tasks, todos)
117+
- **Tool calls**: Formatted by type (Bash commands, file reads/edits, glob/grep patterns, subagent tasks, todos, web fetches, plans)
76118
- **System events**: Context compaction markers

api/export_api.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
"""API endpoint for bulk export."""
1+
"""Export endpoints -- bulk zip download and single-session md/json."""
22

33
import io
4+
import json
45
import zipfile
56
from datetime import datetime
67

7-
from flask import Blueprint, current_app, jsonify, send_file
8+
from flask import Blueprint, current_app, jsonify, request, send_file
89

910
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
1011
from utils.jsonl_parser import parse_session
12+
from utils.session_stats import compute_stats
1113
from utils.md_exporter import session_to_markdown
14+
from utils.json_exporter import session_to_json
1215

1316
export_bp = Blueprint("export", __name__)
1417

@@ -20,6 +23,7 @@ def bulk_export():
2023

2124
buf = io.BytesIO()
2225
count = 0
26+
manifest = []
2327
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
2428
for project in projects:
2529
sessions = list_sessions(project["path"])
@@ -28,15 +32,29 @@ def bulk_export():
2832
session = parse_session(sess_info["path"])
2933
if session["title"] == "Untitled Session":
3034
continue
31-
md = session_to_markdown(session)
32-
title_slug = _slugify(session["title"])[:60]
35+
stats = compute_stats(session)
36+
md = session_to_markdown(session, stats)
37+
title_slug = _slugify(session["title"])[:60] or "session"
3338
short_id = sess_info["id"][:8]
3439
proj_slug = _slugify(project["name"])
3540
rel_path = f"{proj_slug}/{title_slug}__{short_id}.md"
3641
zf.writestr(rel_path, md)
42+
manifest.append({
43+
"session_id": sess_info["id"],
44+
"title": session["title"],
45+
"project": project["name"],
46+
"tokens": session["metadata"]["total_input_tokens"]
47+
+ session["metadata"]["total_output_tokens"],
48+
"tool_calls": session["metadata"]["total_tool_calls"],
49+
"cost_estimate_usd": stats.get("cost_estimate_usd"),
50+
})
3751
count += 1
38-
except Exception:
52+
except Exception as e:
53+
current_app.logger.warning("Failed to export %s: %s", sess_info["id"][:10], e)
3954
continue
55+
if manifest:
56+
manifest_str = "\n".join(json.dumps(e, default=str) for e in manifest)
57+
zf.writestr("manifest.jsonl", manifest_str)
4058

4159
buf.seek(0)
4260
date_tag = datetime.now().strftime("%Y-%m-%d")
@@ -49,20 +67,37 @@ def bulk_export():
4967

5068

5169
@export_bp.route("/api/export/session/<path:project_name>/<session_id>")
52-
def export_session_md(project_name, session_id):
70+
def export_session(project_name, session_id):
5371
import os
72+
from utils.session_path import safe_join
5473
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
55-
filepath = os.path.join(base, project_name, f"{session_id}.jsonl")
74+
try:
75+
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
76+
except ValueError:
77+
return jsonify({"error": "Invalid path"}), 400
5678

5779
if not os.path.isfile(filepath):
5880
return jsonify({"error": "Session not found"}), 404
5981

82+
fmt = request.args.get("format", "md")
6083
session = parse_session(filepath)
61-
md = session_to_markdown(session)
84+
stats = compute_stats(session)
85+
title_slug = _slugify(session["title"])[:60] or "session"
6286

87+
if fmt == "json":
88+
content = session_to_json(session, stats)
89+
buf = io.BytesIO(content.encode("utf-8"))
90+
buf.seek(0)
91+
return send_file(
92+
buf,
93+
mimetype="application/json",
94+
as_attachment=True,
95+
download_name=f"{title_slug}.json",
96+
)
97+
98+
md = session_to_markdown(session, stats)
6399
buf = io.BytesIO(md.encode("utf-8"))
64100
buf.seek(0)
65-
title_slug = _slugify(session["title"])[:60]
66101
return send_file(
67102
buf,
68103
mimetype="text/markdown",

api/projects.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
"""API endpoints for listing projects."""
1+
"""Project listing endpoints."""
2+
3+
import traceback
24

35
from flask import Blueprint, current_app, jsonify
46

5-
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions
7+
from utils.session_path import get_claude_projects_dir, list_projects, list_sessions, safe_join
68

79
projects_bp = Blueprint("projects", __name__)
810

@@ -11,14 +13,40 @@
1113
def get_projects():
1214
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
1315
projects = list_projects(base)
16+
17+
# Enrich each project with accurate titled-session count and latest timestamp
18+
# so the landing page matches what the workspace page shows.
19+
# Uses quick_session_info() which peeks at files without full parsing.
20+
from utils.jsonl_parser import quick_session_info
21+
for project in projects:
22+
sessions = list_sessions(project["path"])
23+
titled_count = 0
24+
latest_ts = None
25+
for s in sessions:
26+
try:
27+
info = quick_session_info(s["path"])
28+
if info["title"] == "Untitled Session":
29+
continue
30+
titled_count += 1
31+
ts = info.get("last_timestamp") or info.get("first_timestamp")
32+
if ts and (latest_ts is None or ts > latest_ts):
33+
latest_ts = ts
34+
except Exception:
35+
titled_count += 1
36+
project["session_count"] = titled_count
37+
if latest_ts:
38+
project["last_modified"] = latest_ts
39+
1440
return jsonify(projects)
1541

1642

1743
@projects_bp.route("/api/projects/<path:project_name>/sessions")
1844
def get_project_sessions(project_name):
1945
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
20-
import os
21-
project_dir = os.path.join(base, project_name)
46+
try:
47+
project_dir = safe_join(base, project_name)
48+
except ValueError:
49+
return jsonify([]), 400
2250
sessions = list_sessions(project_dir)
2351
# Add summary preview for each session
2452
from utils.jsonl_parser import parse_session
@@ -39,6 +67,7 @@ def get_project_sessions(project_name):
3967
"first_timestamp": meta["first_timestamp"],
4068
"last_timestamp": meta["last_timestamp"],
4169
})
42-
except Exception:
43-
result.append({**s, "title": "Error parsing session", "error": True})
70+
except Exception as e:
71+
print(f"[ERROR] Failed to parse {s['id']}: {type(e).__name__}: {e}\n{traceback.format_exc()}")
72+
result.append({**s, "title": "Error parsing session", "error": True, "error_detail": f"{type(e).__name__}: {e}"})
4473
return jsonify(result)

api/search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""API endpoint for full-text search across all sessions."""
1+
"""Search endpoint. Brute-force substring match across all sessions."""
22

33
import os
44

api/sessions.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,57 @@
1-
"""API endpoints for viewing individual sessions."""
1+
"""Session detail and stats endpoints."""
22

33
import os
4+
import traceback
45

56
from flask import Blueprint, current_app, jsonify, abort
67

7-
from utils.session_path import get_claude_projects_dir
8+
from utils.session_path import get_claude_projects_dir, safe_join
89
from utils.jsonl_parser import parse_session
10+
from utils.session_stats import compute_stats
911

1012
sessions_bp = Blueprint("sessions", __name__)
1113

1214

1315
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>")
1416
def get_session(project_name, session_id):
1517
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
16-
filepath = os.path.join(base, project_name, f"{session_id}.jsonl")
18+
try:
19+
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
20+
except ValueError:
21+
return jsonify({"error": "Invalid path"}), 400
1722

1823
if not os.path.isfile(filepath):
19-
abort(404, description=f"Session {session_id} not found")
24+
return jsonify({"error": f"Session {session_id} not found"}), 404
2025

21-
session = parse_session(filepath)
22-
return jsonify(session)
26+
try:
27+
session = parse_session(filepath)
28+
return jsonify(session)
29+
except Exception as e:
30+
tb = traceback.format_exc()
31+
print(f"[ERROR] Failed to parse session {session_id}: {e}\n{tb}")
32+
return jsonify({
33+
"error": f"Failed to parse session: {type(e).__name__}: {e}",
34+
}), 500
35+
36+
37+
@sessions_bp.route("/api/sessions/<path:project_name>/<session_id>/stats")
38+
def get_session_stats(project_name, session_id):
39+
base = current_app.config.get("CLAUDE_PROJECTS_DIR") or get_claude_projects_dir()
40+
try:
41+
filepath = safe_join(base, project_name, f"{session_id}.jsonl")
42+
except ValueError:
43+
return jsonify({"error": "Invalid path"}), 400
44+
45+
if not os.path.isfile(filepath):
46+
return jsonify({"error": f"Session {session_id} not found"}), 404
47+
48+
try:
49+
session = parse_session(filepath)
50+
stats = compute_stats(session)
51+
return jsonify(stats)
52+
except Exception as e:
53+
tb = traceback.format_exc()
54+
print(f"[ERROR] Failed to compute stats for {session_id}: {e}\n{tb}")
55+
return jsonify({
56+
"error": f"Failed to compute stats: {type(e).__name__}: {e}",
57+
}), 500

app.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Flask web application for browsing Claude Code chat history."""
1+
"""Flask app that serves the web GUI for browsing sessions."""
22

33
import os
44

@@ -30,11 +30,11 @@ def index():
3030
import argparse
3131

3232
parser = argparse.ArgumentParser(description="Claude Code Chat Browser")
33-
parser.add_argument("--port", type=int, default=3000)
33+
parser.add_argument("--port", type=int, default=5000)
3434
parser.add_argument("--host", default="127.0.0.1")
3535
parser.add_argument("--base-dir", default=None, help="Override Claude projects dir")
3636
args = parser.parse_args()
3737

3838
app = create_app(base_dir=args.base_dir)
3939
print(f"Claude Code Chat Browser running at http://{args.host}:{args.port}")
40-
app.run(host=args.host, port=args.port, debug=True)
40+
app.run(host=args.host, port=args.port, debug=True, use_reloader=False)

0 commit comments

Comments
 (0)