11"""Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination."""
22
3- import json
43import html
4+ import json
55import os
66import platform
77import re
1313from pathlib import Path
1414
1515import click
16- from click_default_group import DefaultGroup
1716import httpx
18- from jinja2 import Environment , PackageLoader
1917import markdown
2018import 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+
161175def 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+
186243def 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+
245325def 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
@@ -1432,15 +1507,17 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
14321507 return
14331508
14341509 click .echo ("Loading local sessions..." )
1435- results = find_local_sessions ( projects_folder , limit = limit )
1510+ cwd = os . getcwd ( )
14361511
1437- if not results :
1438- click .echo ("No local sessions found." )
1439- return
1512+ current_sessions , project_exists = find_sessions_for_project (
1513+ projects_folder , cwd , limit = limit
1514+ )
1515+
1516+ other_sessions = find_sessions_excluding_project (projects_folder , cwd , limit = limit )
14401517
1441- # Build choices for questionary
14421518 choices = []
1443- for filepath , summary in results :
1519+
1520+ def format_session (filepath , summary , include_project = False ):
14441521 stat = filepath .stat ()
14451522 mod_time = datetime .fromtimestamp (stat .st_mtime )
14461523 size_kb = stat .st_size / 1024
@@ -1449,7 +1526,31 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
14491526 if len (summary ) > 50 :
14501527 summary = summary [:47 ] + "..."
14511528 display = f"{ date_str } { size_kb :5.0f} KB { summary } "
1452- choices .append (questionary .Choice (title = display , value = filepath ))
1529+ if include_project :
1530+ project_name = get_project_display_name (filepath .parent .name )
1531+ display = f"{ display } [{ project_name } ]"
1532+ return display
1533+
1534+ if current_sessions :
1535+ choices .append (questionary .Separator ("── Current Project ──" ))
1536+ for filepath , summary in current_sessions :
1537+ display = format_session (filepath , summary , include_project = False )
1538+ choices .append (questionary .Choice (title = display , value = filepath ))
1539+ elif project_exists :
1540+ choices .append (questionary .Separator ("── Current Project ──" ))
1541+ choices .append (questionary .Separator (" (no sessions found)" ))
1542+ else :
1543+ choices .append (questionary .Separator ("── No sessions for this project ──" ))
1544+
1545+ if other_sessions :
1546+ choices .append (questionary .Separator ("── Other Projects ──" ))
1547+ for filepath , summary in other_sessions :
1548+ display = format_session (filepath , summary , include_project = True )
1549+ choices .append (questionary .Choice (title = display , value = filepath ))
1550+
1551+ if not current_sessions and not other_sessions :
1552+ click .echo ("No local sessions found." )
1553+ return
14531554
14541555 selected = questionary .select (
14551556 "Select a session to convert:" ,
0 commit comments