Skip to content

Commit 596e7b7

Browse files
committed
Prioritize CC sessions from CWD and display them first
1 parent 854a4f8 commit 596e7b7

File tree

2 files changed

+293
-19
lines changed

2 files changed

+293
-19
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 128 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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,53 @@ 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"): # Not recursive - just this project
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+
# Skip if in the excluded project
233+
if f.parent.name == exclude_encoded:
234+
continue
235+
include, summary = _should_include_session(f)
236+
if not include:
237+
continue
238+
results.append((f, summary))
239+
240+
results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True)
241+
return results[:limit]
242+
243+
186244
def get_project_display_name(folder_name):
187245
"""Convert encoded folder name to readable project name.
188246
@@ -242,6 +300,29 @@ def get_project_display_name(folder_name):
242300
return folder_name
243301

244302

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

262343
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)":
344+
include, summary = _should_include_session(session_file, include_agents)
345+
if not include:
270346
continue
271347

272348
# Get project folder
@@ -1432,15 +1508,21 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
14321508
return
14331509

14341510
click.echo("Loading local sessions...")
1435-
results = find_local_sessions(projects_folder, limit=limit)
1511+
cwd = os.getcwd()
14361512

1437-
if not results:
1438-
click.echo("No local sessions found.")
1439-
return
1513+
# Get sessions for current project
1514+
current_sessions, project_exists = find_sessions_for_project(
1515+
projects_folder, cwd, limit=limit
1516+
)
1517+
1518+
# Get sessions from other projects
1519+
other_sessions = find_sessions_excluding_project(projects_folder, cwd, limit=limit)
14401520

1441-
# Build choices for questionary
1521+
# Build choices for questionary with section headers
14421522
choices = []
1443-
for filepath, summary in results:
1523+
1524+
# Helper function to format session display
1525+
def format_session(filepath, summary, include_project=False):
14441526
stat = filepath.stat()
14451527
mod_time = datetime.fromtimestamp(stat.st_mtime)
14461528
size_kb = stat.st_size / 1024
@@ -1449,7 +1531,34 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
14491531
if len(summary) > 50:
14501532
summary = summary[:47] + "..."
14511533
display = f"{date_str} {size_kb:5.0f} KB {summary}"
1452-
choices.append(questionary.Choice(title=display, value=filepath))
1534+
if include_project:
1535+
project_name = get_project_display_name(filepath.parent.name)
1536+
display = f"{display} [{project_name}]"
1537+
return display
1538+
1539+
# Add current project sessions
1540+
if current_sessions:
1541+
choices.append(questionary.Separator("── Current Project ──"))
1542+
for filepath, summary in current_sessions:
1543+
display = format_session(filepath, summary, include_project=False)
1544+
choices.append(questionary.Choice(title=display, value=filepath))
1545+
elif project_exists:
1546+
choices.append(questionary.Separator("── Current Project ──"))
1547+
choices.append(questionary.Separator(" (no sessions found)"))
1548+
else:
1549+
choices.append(questionary.Separator("── No sessions for this project ──"))
1550+
1551+
# Add other project sessions
1552+
if other_sessions:
1553+
choices.append(questionary.Separator("── Other Projects ──"))
1554+
for filepath, summary in other_sessions:
1555+
display = format_session(filepath, summary, include_project=True)
1556+
choices.append(questionary.Choice(title=display, value=filepath))
1557+
1558+
# Handle case where no sessions at all
1559+
if not current_sessions and not other_sessions:
1560+
click.echo("No local sessions found.")
1561+
return
14531562

14541563
selected = questionary.select(
14551564
"Select a session to convert:",

tests/test_all.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88

99
from claude_code_transcripts import (
1010
cli,
11+
encode_path_to_folder_name,
1112
find_all_sessions,
13+
find_sessions_for_project,
14+
find_sessions_excluding_project,
1215
get_project_display_name,
1316
generate_batch_html,
1417
)
@@ -88,6 +91,168 @@ def test_handles_simple_name(self):
8891
assert get_project_display_name("simple-project") == "simple-project"
8992

9093

94+
class TestEncodePathToFolderName:
95+
"""Tests for encode_path_to_folder_name function."""
96+
97+
def test_encodes_simple_path(self):
98+
"""Test encoding simple filesystem path."""
99+
# Note: This will be resolved to absolute path, so test with tempdir
100+
with tempfile.TemporaryDirectory() as tmpdir:
101+
test_path = Path(tmpdir) / "foo" / "bar"
102+
test_path.mkdir(parents=True)
103+
encoded = encode_path_to_folder_name(str(test_path))
104+
# Should replace / with -
105+
assert encoded.startswith("-")
106+
assert "foo-bar" in encoded
107+
108+
def test_encodes_hidden_directory(self):
109+
"""Test encoding path with hidden directory."""
110+
with tempfile.TemporaryDirectory() as tmpdir:
111+
test_path = Path(tmpdir) / ".config"
112+
test_path.mkdir(parents=True)
113+
encoded = encode_path_to_folder_name(str(test_path))
114+
# Hidden directory: /. becomes --
115+
assert "--config" in encoded
116+
117+
def test_encodes_root_path(self):
118+
"""Test encoding root path."""
119+
encoded = encode_path_to_folder_name("/")
120+
assert encoded == "-"
121+
122+
def test_encodes_multiple_hidden_directories(self):
123+
"""Test encoding path with multiple hidden directories."""
124+
with tempfile.TemporaryDirectory() as tmpdir:
125+
test_path = Path(tmpdir) / ".config" / ".cache"
126+
test_path.mkdir(parents=True)
127+
encoded = encode_path_to_folder_name(str(test_path))
128+
# Both should be encoded as --
129+
assert "--config--cache" in encoded
130+
131+
132+
class TestFindSessionsForProject:
133+
"""Tests for find_sessions_for_project function."""
134+
135+
def test_finds_sessions_only_for_specified_project(self, mock_projects_dir):
136+
"""Test that only sessions from the specified project are returned."""
137+
# Simulate looking for project-a sessions
138+
project_a_path = "/home/user/projects/project-a"
139+
sessions, exists = find_sessions_for_project(
140+
mock_projects_dir, project_a_path, limit=10
141+
)
142+
143+
# Should find 2 sessions (agent file excluded)
144+
assert len(sessions) == 2
145+
assert exists is True
146+
147+
# All sessions should be from project-a
148+
for filepath, summary in sessions:
149+
assert filepath.parent.name == "-home-user-projects-project-a"
150+
151+
def test_respects_limit_parameter(self, mock_projects_dir):
152+
"""Test that limit parameter is respected."""
153+
project_a_path = "/home/user/projects/project-a"
154+
sessions, exists = find_sessions_for_project(
155+
mock_projects_dir, project_a_path, limit=1
156+
)
157+
158+
# Should only return 1 session even though 2 are available
159+
assert len(sessions) == 1
160+
assert exists is True
161+
162+
def test_returns_empty_for_nonexistent_project(self, mock_projects_dir):
163+
"""Test handling of non-existent project."""
164+
nonexistent_path = "/home/user/projects/nonexistent"
165+
sessions, exists = find_sessions_for_project(
166+
mock_projects_dir, nonexistent_path, limit=10
167+
)
168+
169+
assert sessions == []
170+
assert exists is False
171+
172+
def test_returns_empty_for_project_with_only_agent_files(self, mock_projects_dir):
173+
"""Test that project with only agent files returns empty list."""
174+
# Create a project with only agent files
175+
agent_only_project = mock_projects_dir / "-home-user-projects-agent-only"
176+
agent_only_project.mkdir(parents=True)
177+
agent_file = agent_only_project / "agent-test.jsonl"
178+
agent_file.write_text(
179+
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "test"}}\n'
180+
)
181+
182+
agent_only_path = "/home/user/projects/agent-only"
183+
sessions, exists = find_sessions_for_project(
184+
mock_projects_dir, agent_only_path, limit=10
185+
)
186+
187+
# Project exists but has no valid sessions
188+
assert sessions == []
189+
assert exists is True
190+
191+
def test_excludes_warmup_sessions(self, mock_projects_dir):
192+
"""Test that warmup sessions are excluded."""
193+
# project-b has 1 regular session and 1 warmup session
194+
project_b_path = "/home/user/projects/project-b"
195+
sessions, exists = find_sessions_for_project(
196+
mock_projects_dir, project_b_path, limit=10
197+
)
198+
199+
# Should only return 1 session (warmup excluded)
200+
assert len(sessions) == 1
201+
assert exists is True
202+
203+
204+
class TestFindSessionsExcludingProject:
205+
"""Tests for find_sessions_excluding_project function."""
206+
207+
def test_finds_sessions_excluding_specified_project(self, mock_projects_dir):
208+
"""Test that sessions from the excluded project are not returned."""
209+
exclude_path = "/home/user/projects/project-a"
210+
sessions = find_sessions_excluding_project(
211+
mock_projects_dir, exclude_path, limit=10
212+
)
213+
214+
# Should find 1 session from project-b (warmup excluded)
215+
assert len(sessions) == 1
216+
217+
# Session should not be from project-a
218+
for filepath, summary in sessions:
219+
assert filepath.parent.name != "-home-user-projects-project-a"
220+
assert filepath.parent.name == "-home-user-projects-project-b"
221+
222+
def test_respects_limit_parameter(self, mock_projects_dir):
223+
"""Test that limit parameter is respected."""
224+
# Exclude project-a, so we get project-b's sessions
225+
exclude_path = "/home/user/projects/project-a"
226+
sessions = find_sessions_excluding_project(
227+
mock_projects_dir, exclude_path, limit=1
228+
)
229+
230+
# Should only return 1 session
231+
assert len(sessions) == 1
232+
233+
def test_excludes_agent_files(self, mock_projects_dir):
234+
"""Test that agent files are excluded."""
235+
exclude_path = "/home/user/projects/nonexistent"
236+
sessions = find_sessions_excluding_project(
237+
mock_projects_dir, exclude_path, limit=10
238+
)
239+
240+
# Should not find agent files
241+
for filepath, summary in sessions:
242+
assert not filepath.name.startswith("agent-")
243+
244+
def test_excludes_warmup_sessions(self, mock_projects_dir):
245+
"""Test that warmup sessions are excluded."""
246+
exclude_path = "/home/user/projects/nonexistent"
247+
sessions = find_sessions_excluding_project(
248+
mock_projects_dir, exclude_path, limit=10
249+
)
250+
251+
# Should not find warmup sessions (warmup123.jsonl in project-b)
252+
for filepath, summary in sessions:
253+
assert summary.lower() != "warmup"
254+
255+
91256
class TestFindAllSessions:
92257
"""Tests for find_all_sessions function."""
93258

0 commit comments

Comments
 (0)