Skip to content

Commit c86db2d

Browse files
committed
Merge upstream PR simonw#49: Prioritize sessions from CWD
2 parents 6ee2372 + 3f3d42c commit c86db2d

File tree

2 files changed

+274
-25
lines changed

2 files changed

+274
-25
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 123 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination."""
22

3-
import json
43
import html
4+
import json
55
import os
66
import platform
77
import re
@@ -13,11 +13,11 @@
1313
from pathlib import Path
1414

1515
import click
16-
from click_default_group import DefaultGroup
1716
import httpx
18-
from jinja2 import Environment, PackageLoader
1917
import markdown
2018
import questionary
19+
from click_default_group import DefaultGroup
20+
from jinja2 import Environment, PackageLoader
2121

2222
# Set up Jinja2 environment
2323
_jinja_env = Environment(
@@ -158,6 +158,20 @@ def _get_jsonl_summary(filepath, max_length=200):
158158
return "(no summary)"
159159

160160

161+
def _should_include_session(filepath, include_agents=False):
162+
"""Check if a session file should be included in listings.
163+
164+
Returns (True, summary) if the session should be included,
165+
(False, None) if it should be skipped.
166+
"""
167+
if not include_agents and filepath.name.startswith("agent-"):
168+
return False, None
169+
summary = get_session_summary(filepath)
170+
if summary.lower() == "warmup" or summary == "(no summary)":
171+
return False, None
172+
return True, summary
173+
174+
161175
def find_local_sessions(folder, limit=10):
162176
"""Find recent JSONL session files in the given folder.
163177
@@ -170,11 +184,8 @@ def find_local_sessions(folder, limit=10):
170184

171185
results = []
172186
for f in folder.glob("**/*.jsonl"):
173-
if f.name.startswith("agent-"):
174-
continue
175-
summary = get_session_summary(f)
176-
# Skip boring/empty sessions
177-
if summary.lower() == "warmup" or summary == "(no summary)":
187+
include, summary = _should_include_session(f)
188+
if not include:
178189
continue
179190
results.append((f, summary))
180191

@@ -183,6 +194,52 @@ def find_local_sessions(folder, limit=10):
183194
return results[:limit]
184195

185196

197+
def find_sessions_for_project(projects_folder, project_path, limit=10):
198+
"""Find sessions for a specific project directory.
199+
200+
Returns (sessions_list, project_folder_exists) where:
201+
- sessions_list: list of (Path, summary) tuples sorted by modification time
202+
- project_folder_exists: True if the project folder exists, False otherwise
203+
"""
204+
projects_folder = Path(projects_folder)
205+
encoded = encode_path_to_folder_name(project_path)
206+
project_folder = projects_folder / encoded
207+
208+
if not project_folder.exists():
209+
return [], False
210+
211+
results = []
212+
for f in project_folder.glob("*.jsonl"):
213+
include, summary = _should_include_session(f)
214+
if not include:
215+
continue
216+
results.append((f, summary))
217+
218+
results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True)
219+
return results[:limit], True
220+
221+
222+
def find_sessions_excluding_project(projects_folder, exclude_project_path, limit=10):
223+
"""Find recent sessions from all projects except the specified one.
224+
225+
Returns a list of (Path, summary) tuples sorted by modification time.
226+
"""
227+
projects_folder = Path(projects_folder)
228+
exclude_encoded = encode_path_to_folder_name(exclude_project_path)
229+
230+
results = []
231+
for f in projects_folder.glob("**/*.jsonl"):
232+
if f.parent.name == exclude_encoded:
233+
continue
234+
include, summary = _should_include_session(f)
235+
if not include:
236+
continue
237+
results.append((f, summary))
238+
239+
results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True)
240+
return results[:limit]
241+
242+
186243
def get_project_display_name(folder_name):
187244
"""Convert encoded folder name to readable project name.
188245
@@ -242,6 +299,29 @@ def get_project_display_name(folder_name):
242299
return folder_name
243300

244301

302+
def encode_path_to_folder_name(path):
303+
"""Encode a filesystem path to Claude's project folder naming convention.
304+
305+
This is the inverse of how Claude Code stores project folders:
306+
- /Users/foo/bar -> -Users-foo-bar
307+
- /Users/foo/.hidden -> -Users-foo--hidden
308+
309+
Note: This is NOT the inverse of get_project_display_name(), which is
310+
a one-way beautifier that extracts meaningful project names for display.
311+
"""
312+
# Convert to absolute path but don't resolve symlinks
313+
# (resolve() doesn't work well with non-existent paths)
314+
p = Path(path)
315+
if not p.is_absolute():
316+
p = Path.cwd() / p
317+
path = str(p)
318+
# Hidden directories: /. becomes --
319+
encoded = path.replace("/.", "--")
320+
# All other slashes become dashes
321+
encoded = encoded.replace("/", "-")
322+
return encoded
323+
324+
245325
def find_all_sessions(folder, include_agents=False):
246326
"""Find all sessions in a Claude projects folder, grouped by project.
247327
@@ -260,13 +340,8 @@ def find_all_sessions(folder, include_agents=False):
260340
projects = {}
261341

262342
for session_file in folder.glob("**/*.jsonl"):
263-
# Skip agent files unless requested
264-
if not include_agents and session_file.name.startswith("agent-"):
265-
continue
266-
267-
# Get summary and skip boring sessions
268-
summary = get_session_summary(session_file)
269-
if summary.lower() == "warmup" or summary == "(no summary)":
343+
include, summary = _should_include_session(session_file, include_agents)
344+
if not include:
270345
continue
271346

272347
# Get project folder
@@ -1457,15 +1532,17 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
14571532
return
14581533

14591534
click.echo("Loading local sessions...")
1460-
results = find_local_sessions(projects_folder, limit=limit)
1535+
cwd = os.getcwd()
14611536

1462-
if not results:
1463-
click.echo("No local sessions found.")
1464-
return
1537+
current_sessions, project_exists = find_sessions_for_project(
1538+
projects_folder, cwd, limit=limit
1539+
)
1540+
1541+
other_sessions = find_sessions_excluding_project(projects_folder, cwd, limit=limit)
14651542

1466-
# Build choices for questionary
14671543
choices = []
1468-
for filepath, summary in results:
1544+
1545+
def format_session(filepath, summary, include_project=False):
14691546
stat = filepath.stat()
14701547
mod_time = datetime.fromtimestamp(stat.st_mtime)
14711548
size_kb = stat.st_size / 1024
@@ -1474,7 +1551,31 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
14741551
if len(summary) > 50:
14751552
summary = summary[:47] + "..."
14761553
display = f"{date_str} {size_kb:5.0f} KB {summary}"
1477-
choices.append(questionary.Choice(title=display, value=filepath))
1554+
if include_project:
1555+
project_name = get_project_display_name(filepath.parent.name)
1556+
display = f"{display} [{project_name}]"
1557+
return display
1558+
1559+
if current_sessions:
1560+
choices.append(questionary.Separator("── Current Project ──"))
1561+
for filepath, summary in current_sessions:
1562+
display = format_session(filepath, summary, include_project=False)
1563+
choices.append(questionary.Choice(title=display, value=filepath))
1564+
elif project_exists:
1565+
choices.append(questionary.Separator("── Current Project ──"))
1566+
choices.append(questionary.Separator(" (no sessions found)"))
1567+
else:
1568+
choices.append(questionary.Separator("── No sessions for this project ──"))
1569+
1570+
if other_sessions:
1571+
choices.append(questionary.Separator("── Other Projects ──"))
1572+
for filepath, summary in other_sessions:
1573+
display = format_session(filepath, summary, include_project=True)
1574+
choices.append(questionary.Choice(title=display, value=filepath))
1575+
1576+
if not current_sessions and not other_sessions:
1577+
click.echo("No local sessions found.")
1578+
return
14781579

14791580
selected = questionary.select(
14801581
"Select a session to convert:",

0 commit comments

Comments
 (0)