Skip to content

Commit d91d86c

Browse files
committed
Add unified session picker for both Claude Code and Codex CLI
- Implemented find_combined_sessions() to search both ~/.claude/projects and ~/.codex/sessions - Updated get_session_summary() to extract summaries from Codex CLI format (response_item payloads) - Modified local command to show sessions from both sources with clear [Claude] and [Codex] labels - Sessions are sorted together by modification time across both sources - Added 5 comprehensive tests for combined session finder - Updated documentation to reflect unified picker functionality Users can now run `claude-code-transcripts` and see an interactive picker showing: 2025-01-02 10:00 245 KB [Claude] Fix authentication bug 2025-01-01 14:30 189 KB [Codex ] Add dark mode feature 2025-01-01 09:15 312 KB [Claude] Refactor API endpoints All 123 tests passing (111 original + 7 Codex format + 5 Codex finder).
1 parent a78fed7 commit d91d86c

File tree

3 files changed

+254
-12
lines changed

3 files changed

+254
-12
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ This tool converts Claude Code and Codex CLI session files into browseable multi
2828

2929
**Supported formats:**
3030
- Claude Code session files (JSONL format from `~/.claude/projects`)
31-
- Codex CLI session files (JSONL format) - automatically detected and converted
31+
- Codex CLI session files (JSONL format from `~/.codex/sessions`) - automatically detected and converted
3232

3333
There are four commands available:
3434

35-
- `local` (default) - select from local Claude Code sessions stored in `~/.claude/projects`
35+
- `local` (default) - select from local sessions (Claude Code from `~/.claude/projects` and Codex CLI from `~/.codex/sessions`)
3636
- `web` - select from web sessions via the Claude API
3737
- `json` - convert a specific JSON or JSONL session file
3838
- `all` - convert all local sessions to a browsable HTML archive
@@ -43,7 +43,7 @@ The quickest way to view a recent local session:
4343
claude-code-transcripts
4444
```
4545

46-
This shows an interactive picker to select a session, generates HTML, and opens it in your default browser.
46+
This shows an interactive picker with sessions from both Claude Code and Codex CLI, clearly labeled by source. Select any session to generate HTML and open it in your browser.
4747

4848
### Output options
4949

src/claude_code_transcripts/__init__.py

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ def _get_jsonl_summary(filepath, max_length=200):
139139
continue
140140
try:
141141
obj = json.loads(line)
142+
143+
# Claude Code format: {"type": "user", "message": {...}}
142144
if (
143145
obj.get("type") == "user"
144146
and not obj.get("isMeta")
@@ -150,6 +152,25 @@ def _get_jsonl_summary(filepath, max_length=200):
150152
if len(text) > max_length:
151153
return text[: max_length - 3] + "..."
152154
return text
155+
156+
# Codex CLI format: {"type": "response_item", "payload": {"type": "message", "role": "user", "content": [...]}}
157+
elif obj.get("type") == "response_item":
158+
payload = obj.get("payload", {})
159+
if (
160+
payload.get("type") == "message"
161+
and payload.get("role") == "user"
162+
and payload.get("content")
163+
):
164+
content_blocks = payload["content"]
165+
# Extract text from Codex CLI content blocks
166+
if isinstance(content_blocks, list):
167+
for block in content_blocks:
168+
if block.get("type") == "input_text":
169+
text = block.get("text", "")
170+
if text and not text.startswith("<"):
171+
if len(text) > max_length:
172+
return text[: max_length - 3] + "..."
173+
return text
153174
except json.JSONDecodeError:
154175
continue
155176
except Exception:
@@ -183,6 +204,53 @@ def find_local_sessions(folder, limit=10):
183204
return results[:limit]
184205

185206

207+
def find_combined_sessions(claude_dir=None, codex_dir=None, limit=10):
208+
"""Find recent sessions from both Claude Code and Codex CLI directories.
209+
210+
Args:
211+
claude_dir: Path to Claude Code projects folder (default: ~/.claude/projects)
212+
codex_dir: Path to Codex CLI sessions folder (default: ~/.codex/sessions)
213+
limit: Maximum number of sessions to return (default: 10)
214+
215+
Returns:
216+
List of (Path, summary, source) tuples sorted by modification time (newest first).
217+
source is either "Claude" or "Codex".
218+
"""
219+
if claude_dir is None:
220+
claude_dir = Path.home() / ".claude" / "projects"
221+
if codex_dir is None:
222+
codex_dir = Path.home() / ".codex" / "sessions"
223+
224+
claude_dir = Path(claude_dir)
225+
codex_dir = Path(codex_dir)
226+
227+
results = []
228+
229+
# Find Claude Code sessions
230+
if claude_dir.exists():
231+
for f in claude_dir.glob("**/*.jsonl"):
232+
if f.name.startswith("agent-"):
233+
continue
234+
summary = get_session_summary(f)
235+
if summary.lower() == "warmup" or summary == "(no summary)":
236+
continue
237+
results.append((f, summary, "Claude"))
238+
239+
# Find Codex CLI sessions
240+
if codex_dir.exists():
241+
for f in codex_dir.glob("**/*.jsonl"):
242+
if f.name.startswith("agent-"):
243+
continue
244+
summary = get_session_summary(f)
245+
if summary.lower() == "warmup" or summary == "(no summary)":
246+
continue
247+
results.append((f, summary, "Codex"))
248+
249+
# Sort by modification time, most recent first
250+
results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True)
251+
return results[:limit]
252+
253+
186254
def get_project_display_name(folder_name):
187255
"""Convert encoded folder name to readable project name.
188256
@@ -1585,32 +1653,36 @@ def cli():
15851653
help="Maximum number of sessions to show (default: 10)",
15861654
)
15871655
def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit):
1588-
"""Select and convert a local Claude Code session to HTML."""
1656+
"""Select and convert a local Claude Code or Codex CLI session to HTML."""
15891657
projects_folder = Path.home() / ".claude" / "projects"
1658+
codex_folder = Path.home() / ".codex" / "sessions"
15901659

1591-
if not projects_folder.exists():
1592-
click.echo(f"Projects folder not found: {projects_folder}")
1593-
click.echo("No local Claude Code sessions available.")
1660+
# Check if at least one directory exists
1661+
if not projects_folder.exists() and not codex_folder.exists():
1662+
click.echo(f"Neither Claude Code nor Codex CLI sessions found.")
1663+
click.echo(f" - Claude Code: {projects_folder}")
1664+
click.echo(f" - Codex CLI: {codex_folder}")
15941665
return
15951666

15961667
click.echo("Loading local sessions...")
1597-
results = find_local_sessions(projects_folder, limit=limit)
1668+
results = find_combined_sessions(limit=limit)
15981669

15991670
if not results:
16001671
click.echo("No local sessions found.")
16011672
return
16021673

16031674
# Build choices for questionary
16041675
choices = []
1605-
for filepath, summary in results:
1676+
for filepath, summary, source in results:
16061677
stat = filepath.stat()
16071678
mod_time = datetime.fromtimestamp(stat.st_mtime)
16081679
size_kb = stat.st_size / 1024
16091680
date_str = mod_time.strftime("%Y-%m-%d %H:%M")
16101681
# Truncate summary if too long
1611-
if len(summary) > 50:
1612-
summary = summary[:47] + "..."
1613-
display = f"{date_str} {size_kb:5.0f} KB {summary}"
1682+
if len(summary) > 45:
1683+
summary = summary[:42] + "..."
1684+
# Add source label
1685+
display = f"{date_str} {size_kb:5.0f} KB [{source:6s}] {summary}"
16141686
choices.append(questionary.Choice(title=display, value=filepath))
16151687

16161688
selected = questionary.select(

tests/test_codex_cli_finder.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Tests for finding sessions from both Claude Code and Codex CLI directories."""
2+
3+
import tempfile
4+
from pathlib import Path
5+
import time
6+
7+
import pytest
8+
9+
from claude_code_transcripts import find_local_sessions, find_combined_sessions
10+
11+
12+
class TestFindCombinedSessions:
13+
"""Tests for finding sessions from both ~/.claude/projects and ~/.codex/sessions."""
14+
15+
def test_finds_sessions_from_both_directories(self):
16+
"""Test that sessions from both Claude and Codex directories are found."""
17+
with tempfile.TemporaryDirectory() as tmpdir:
18+
tmpdir = Path(tmpdir)
19+
20+
# Create mock Claude projects directory
21+
claude_dir = tmpdir / "claude_projects" / "project-a"
22+
claude_dir.mkdir(parents=True)
23+
claude_session = claude_dir / "session1.jsonl"
24+
claude_session.write_text(
25+
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Claude session"}}\n'
26+
)
27+
28+
# Create mock Codex sessions directory
29+
codex_dir = tmpdir / "codex_sessions"
30+
codex_dir.mkdir(parents=True)
31+
codex_session = codex_dir / "rollout-2025-12-28T10-00-00-abc123.jsonl"
32+
codex_session.write_text(
33+
'{"timestamp":"2025-12-28T10:00:00.000Z","type":"session_meta","payload":{"id":"abc123"}}\n'
34+
'{"timestamp":"2025-12-28T10:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Codex session"}]}}\n'
35+
)
36+
37+
# Find sessions from both
38+
results = find_combined_sessions(
39+
claude_dir=tmpdir / "claude_projects", codex_dir=codex_dir
40+
)
41+
42+
# Should find both
43+
assert len(results) == 2
44+
paths = [r[0] for r in results]
45+
assert claude_session in paths
46+
assert codex_session in paths
47+
48+
def test_labels_sessions_by_source(self):
49+
"""Test that sessions include source labels (Claude or Codex)."""
50+
with tempfile.TemporaryDirectory() as tmpdir:
51+
tmpdir = Path(tmpdir)
52+
53+
# Create one of each type
54+
claude_dir = tmpdir / "claude_projects" / "project-a"
55+
claude_dir.mkdir(parents=True)
56+
claude_session = claude_dir / "session1.jsonl"
57+
claude_session.write_text(
58+
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Test"}}\n'
59+
)
60+
61+
codex_dir = tmpdir / "codex_sessions"
62+
codex_dir.mkdir(parents=True)
63+
codex_session = codex_dir / "rollout-2025-12-28T10-00-00-abc123.jsonl"
64+
codex_session.write_text(
65+
'{"timestamp":"2025-12-28T10:00:00.000Z","type":"session_meta","payload":{"id":"abc123"}}\n'
66+
'{"timestamp":"2025-12-28T10:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Test"}]}}\n'
67+
)
68+
69+
results = find_combined_sessions(
70+
claude_dir=tmpdir / "claude_projects", codex_dir=codex_dir
71+
)
72+
73+
# Results should be (Path, summary, source) tuples
74+
assert len(results) == 2
75+
76+
claude_result = next(r for r in results if r[0] == claude_session)
77+
codex_result = next(r for r in results if r[0] == codex_session)
78+
79+
# Check source labels
80+
assert claude_result[2] == "Claude"
81+
assert codex_result[2] == "Codex"
82+
83+
def test_sorts_combined_by_modification_time(self):
84+
"""Test that all sessions are sorted together by modification time."""
85+
with tempfile.TemporaryDirectory() as tmpdir:
86+
tmpdir = Path(tmpdir)
87+
88+
# Create older Claude session
89+
claude_dir = tmpdir / "claude_projects" / "project-a"
90+
claude_dir.mkdir(parents=True)
91+
old_claude = claude_dir / "old.jsonl"
92+
old_claude.write_text(
93+
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Old"}}\n'
94+
)
95+
96+
time.sleep(0.1)
97+
98+
# Create newer Codex session
99+
codex_dir = tmpdir / "codex_sessions"
100+
codex_dir.mkdir(parents=True)
101+
new_codex = codex_dir / "rollout-2025-12-28T10-00-00-abc123.jsonl"
102+
new_codex.write_text(
103+
'{"timestamp":"2025-12-28T10:00:00.000Z","type":"session_meta","payload":{"id":"abc123"}}\n'
104+
'{"timestamp":"2025-12-28T10:00:00.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"New"}]}}\n'
105+
)
106+
107+
results = find_combined_sessions(
108+
claude_dir=tmpdir / "claude_projects", codex_dir=codex_dir
109+
)
110+
111+
# Newer file should be first regardless of source
112+
assert results[0][0] == new_codex
113+
assert results[1][0] == old_claude
114+
115+
def test_respects_limit_across_both_sources(self):
116+
"""Test that limit applies to combined results."""
117+
with tempfile.TemporaryDirectory() as tmpdir:
118+
tmpdir = Path(tmpdir)
119+
120+
# Create 3 Claude sessions
121+
claude_dir = tmpdir / "claude_projects" / "project-a"
122+
claude_dir.mkdir(parents=True)
123+
for i in range(3):
124+
f = claude_dir / f"session{i}.jsonl"
125+
f.write_text(
126+
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Test"}}\n'
127+
)
128+
129+
# Create 3 Codex sessions
130+
codex_dir = tmpdir / "codex_sessions"
131+
codex_dir.mkdir(parents=True)
132+
for i in range(3):
133+
f = codex_dir / f"rollout-2025-12-28T10-00-0{i}-test{i}.jsonl"
134+
f.write_text(
135+
f'{{"timestamp":"2025-12-28T10:00:0{i}.000Z","type":"session_meta","payload":{{"id":"test{i}"}}}}\n'
136+
f'{{"timestamp":"2025-12-28T10:00:0{i}.000Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"Test"}}]}}}}\n'
137+
)
138+
139+
# Request only 4 total
140+
results = find_combined_sessions(
141+
claude_dir=tmpdir / "claude_projects", codex_dir=codex_dir, limit=4
142+
)
143+
144+
assert len(results) == 4
145+
146+
def test_handles_missing_directories(self):
147+
"""Test that missing directories don't cause errors."""
148+
# Both missing
149+
results = find_combined_sessions(
150+
claude_dir=Path("/nonexistent/claude"),
151+
codex_dir=Path("/nonexistent/codex"),
152+
)
153+
assert results == []
154+
155+
# Only Claude exists
156+
with tempfile.TemporaryDirectory() as tmpdir:
157+
tmpdir = Path(tmpdir)
158+
claude_dir = tmpdir / "claude_projects" / "project-a"
159+
claude_dir.mkdir(parents=True)
160+
session = claude_dir / "session1.jsonl"
161+
session.write_text(
162+
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Test"}}\n'
163+
)
164+
165+
results = find_combined_sessions(
166+
claude_dir=tmpdir / "claude_projects",
167+
codex_dir=Path("/nonexistent/codex"),
168+
)
169+
assert len(results) == 1
170+
assert results[0][2] == "Claude"

0 commit comments

Comments
 (0)