cchat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cchat.py ADDED
@@ -0,0 +1,1588 @@
1
+ #!/usr/bin/env python3
2
+ """Browse and search Claude Code conversation history from the terminal."""
3
+
4
+ from __future__ import annotations
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ import argparse
9
+ import json
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ from collections import defaultdict
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ # ═══════════════════════════════════════════════════════════════════════════════
20
+ # CONSTANTS
21
+ # ═══════════════════════════════════════════════════════════════════════════════
22
+
23
+ CLAUDE_DIR = Path.home() / ".claude"
24
+ PROJECTS_DIR = CLAUDE_DIR / "projects"
25
+ DEFAULT_TURNS = 5
26
+ MAX_WORKERS = 4
27
+
28
+ # Entry types that participate in the UUID tree
29
+ TREE_TYPES = {"user", "assistant", "system", "progress"}
30
+
31
+ # Entry types that are flat metadata (no UUID, not in tree)
32
+ METADATA_TYPES = {"summary", "file-history-snapshot", "queue-operation", "custom-title"}
33
+
34
+ # ═══════════════════════════════════════════════════════════════════════════════
35
+ # DATA CLASSES
36
+ # ═══════════════════════════════════════════════════════════════════════════════
37
+
38
+
39
+ @dataclass
40
+ class ToolSummary:
41
+ name: str
42
+ input_data: dict
43
+
44
+ def one_line(self) -> str:
45
+ """Format tool call as a concise one-liner."""
46
+ inp = self.input_data
47
+ if self.name == "Read":
48
+ path = inp.get("file_path", "?")
49
+ return f"[Read] {_short_path(path)}"
50
+ elif self.name == "Write":
51
+ path = inp.get("file_path", "?")
52
+ return f"[Write] {_short_path(path)}"
53
+ elif self.name == "Edit":
54
+ path = inp.get("file_path", "?")
55
+ return f"[Edit] {_short_path(path)}"
56
+ elif self.name == "Bash":
57
+ cmd = inp.get("command", "?")
58
+ desc = inp.get("description", "")
59
+ label = desc if desc else (cmd[:60] + "..." if len(cmd) > 60 else cmd)
60
+ return f"[Bash] {label}"
61
+ elif self.name == "Glob":
62
+ pattern = inp.get("pattern", "?")
63
+ return f"[Glob] {pattern}"
64
+ elif self.name == "Grep":
65
+ pattern = inp.get("pattern", "?")
66
+ return f"[Grep] {pattern}"
67
+ elif self.name == "Task":
68
+ desc = inp.get("description", "?")
69
+ return f"[Task] {desc}"
70
+ elif self.name == "WebFetch":
71
+ url = inp.get("url", "?")
72
+ return f"[WebFetch] {url[:60]}"
73
+ elif self.name == "WebSearch":
74
+ query = inp.get("query", "?")
75
+ return f"[WebSearch] {query}"
76
+ elif self.name == "TodoWrite" or self.name == "TaskCreate":
77
+ return f"[{self.name}]"
78
+ else:
79
+ # Generic
80
+ summary = json.dumps(inp)
81
+ if len(summary) > 60:
82
+ summary = summary[:60] + "..."
83
+ return f"[{self.name}] {summary}"
84
+
85
+
86
+ @dataclass
87
+ class Turn:
88
+ """A conversation turn: one user message + full assistant response."""
89
+ user_text: str
90
+ assistant_text: str
91
+ tool_calls: list # list[ToolSummary]
92
+ timestamp: str
93
+ uuid: str
94
+ is_compact_summary: bool = False
95
+
96
+
97
+ @dataclass
98
+ class RawMessage:
99
+ """A single raw message for --raw mode."""
100
+ role: str # "user", "assistant", "user (tool_result)", "assistant (tool)", "system", "thinking"
101
+ content: str
102
+ timestamp: str
103
+ uuid: str
104
+ entry_type: str # original entry type
105
+
106
+
107
+ @dataclass
108
+ class SessionMeta:
109
+ session_id: str
110
+ summary: str
111
+ first_prompt: str
112
+ message_count: int
113
+ created: str
114
+ modified: str
115
+ path: Optional[Path] = None
116
+
117
+
118
+ @dataclass
119
+ class BranchPoint:
120
+ parent_uuid: str
121
+ active_child_uuid: str
122
+ alternative_uuids: list # list[str]
123
+ line_index: int # file position of the parent entry
124
+
125
+
126
+ # ═══════════════════════════════════════════════════════════════════════════════
127
+ # UTILITIES
128
+ # ═══════════════════════════════════════════════════════════════════════════════
129
+
130
+
131
+ def _short_path(path: str, max_parts: int = 3) -> str:
132
+ """Shorten a file path for display."""
133
+ parts = Path(path).parts
134
+ if len(parts) <= max_parts:
135
+ return path
136
+ return ".../" + "/".join(parts[-max_parts:])
137
+
138
+
139
+ def _parse_timestamp(ts: str) -> datetime:
140
+ """Parse ISO timestamp, returning datetime.min on failure."""
141
+ if not ts:
142
+ return datetime.min
143
+ try:
144
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
145
+ except (ValueError, TypeError):
146
+ return datetime.min
147
+
148
+
149
+ def _truncate(text: str, max_len: int) -> str:
150
+ """Truncate text with ellipsis."""
151
+ if max_len <= 0 or len(text) <= max_len:
152
+ return text
153
+ return text[:max_len] + "..."
154
+
155
+
156
+ # ═══════════════════════════════════════════════════════════════════════════════
157
+ # PROJECT RESOLUTION
158
+ # ═══════════════════════════════════════════════════════════════════════════════
159
+
160
+
161
+ class ProjectResolver:
162
+ """Find and manage project directories."""
163
+
164
+ @staticmethod
165
+ def get_project_key(cwd: Path) -> str:
166
+ """Convert cwd to Claude's project directory name format."""
167
+ abs_path = str(cwd.resolve())
168
+ return abs_path.replace("/", "-")
169
+
170
+ @staticmethod
171
+ def find_project_dir(cwd: Path) -> Optional[Path]:
172
+ """Find Claude project directory for a working directory."""
173
+ project_key = ProjectResolver.get_project_key(cwd)
174
+ project_path = PROJECTS_DIR / project_key
175
+
176
+ if project_path.exists():
177
+ return project_path
178
+
179
+ # Case-insensitive match (WSL path casing can vary)
180
+ if PROJECTS_DIR.exists():
181
+ for d in PROJECTS_DIR.iterdir():
182
+ if d.is_dir() and d.name.lower() == project_key.lower():
183
+ return d
184
+ return None
185
+
186
+ @staticmethod
187
+ def find_project_dir_for_path(project_path: str) -> Optional[Path]:
188
+ """Find project dir from a user-provided path string."""
189
+ # Normalize the path
190
+ p = Path(project_path).resolve()
191
+ return ProjectResolver.find_project_dir(p)
192
+
193
+ @staticmethod
194
+ def list_all_projects() -> list[dict]:
195
+ """List all project directories with metadata."""
196
+ if not PROJECTS_DIR.exists():
197
+ return []
198
+
199
+ projects = []
200
+ for d in sorted(PROJECTS_DIR.iterdir()):
201
+ if not d.is_dir():
202
+ continue
203
+ # Count session files (exclude agent- files and subdirectories)
204
+ session_files = [
205
+ f for f in d.glob("*.jsonl")
206
+ if not f.name.startswith("agent-")
207
+ ]
208
+ if not session_files:
209
+ continue
210
+
211
+ # Decode project path from dir name
212
+ decoded_path = d.name.replace("-", "/", 1) # first dash is the leading /
213
+ # Actually the format is: -mnt-c-Users-... where each - is a /
214
+ decoded_path = d.name.replace("-", "/")
215
+ if decoded_path.startswith("/"):
216
+ pass # already correct
217
+ else:
218
+ decoded_path = "/" + decoded_path
219
+
220
+ latest_mtime = max(f.stat().st_mtime for f in session_files)
221
+ projects.append({
222
+ "dir": d,
223
+ "name": d.name,
224
+ "decoded_path": decoded_path,
225
+ "session_count": len(session_files),
226
+ "latest_modified": datetime.fromtimestamp(latest_mtime),
227
+ })
228
+
229
+ projects.sort(key=lambda p: p["latest_modified"], reverse=True)
230
+ return projects
231
+
232
+ @staticmethod
233
+ def get_project_dir_or_exit(project_override: Optional[str] = None) -> Path:
234
+ """Get project directory or exit with error."""
235
+ if project_override:
236
+ project_dir = ProjectResolver.find_project_dir_for_path(project_override)
237
+ if not project_dir:
238
+ # Try direct name match
239
+ candidate = PROJECTS_DIR / project_override
240
+ if candidate.exists():
241
+ return candidate
242
+ # Try partial match
243
+ if PROJECTS_DIR.exists():
244
+ for d in PROJECTS_DIR.iterdir():
245
+ if d.is_dir() and project_override.lower() in d.name.lower():
246
+ return d
247
+ print(f"Error: No project found for '{project_override}'", file=sys.stderr)
248
+ print("Use 'cchat projects' to list available projects.", file=sys.stderr)
249
+ sys.exit(1)
250
+ return project_dir
251
+
252
+ cwd = Path.cwd()
253
+ project_dir = ProjectResolver.find_project_dir(cwd)
254
+ if not project_dir:
255
+ print(f"Error: No Claude project found for {cwd}", file=sys.stderr)
256
+ print(f"Expected: {PROJECTS_DIR / ProjectResolver.get_project_key(cwd)}", file=sys.stderr)
257
+ print("Use 'cchat projects' to list available projects.", file=sys.stderr)
258
+ print("Use '--project PATH' to specify a different project.", file=sys.stderr)
259
+ sys.exit(1)
260
+ return project_dir
261
+
262
+
263
+ # ═══════════════════════════════════════════════════════════════════════════════
264
+ # SESSION INDEX
265
+ # ═══════════════════════════════════════════════════════════════════════════════
266
+
267
+
268
+ class SessionIndex:
269
+ """Fast session metadata with sessions-index.json + fallback."""
270
+
271
+ def __init__(self, project_dir: Path):
272
+ self.project_dir = project_dir
273
+ self._index_cache: Optional[dict] = None
274
+
275
+ def _load_index(self) -> dict:
276
+ """Load sessions-index.json if present."""
277
+ idx_path = self.project_dir / "sessions-index.json"
278
+ if idx_path.exists():
279
+ try:
280
+ with open(idx_path, "r", encoding="utf-8") as f:
281
+ data = json.load(f)
282
+ return {e["sessionId"]: e for e in data.get("entries", [])}
283
+ except (json.JSONDecodeError, KeyError):
284
+ pass
285
+ return {}
286
+
287
+ def _get_index(self) -> dict:
288
+ if self._index_cache is None:
289
+ self._index_cache = self._load_index()
290
+ return self._index_cache
291
+
292
+ def get_metadata(self, session_id: str, jsonl_path: Path) -> SessionMeta:
293
+ """Get session metadata. Fast path uses index, slow path reads file header."""
294
+ idx = self._get_index()
295
+
296
+ if session_id in idx:
297
+ entry = idx[session_id]
298
+ return SessionMeta(
299
+ session_id=session_id,
300
+ summary=entry.get("summary", ""),
301
+ first_prompt=entry.get("firstPrompt", "")[:200],
302
+ message_count=entry.get("messageCount", 0),
303
+ created=entry.get("created", ""),
304
+ modified=entry.get("modified", ""),
305
+ path=jsonl_path,
306
+ )
307
+
308
+ # Slow path: read file header
309
+ summary = ""
310
+ first_prompt = ""
311
+ custom_title = ""
312
+ try:
313
+ with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f:
314
+ for i, line in enumerate(f):
315
+ if i > 50:
316
+ break
317
+ try:
318
+ d = json.loads(line)
319
+ t = d.get("type")
320
+ if t == "summary" and not summary:
321
+ summary = d.get("summary", "")
322
+ elif t == "custom-title":
323
+ custom_title = d.get("customTitle", d.get("title", ""))
324
+ elif t == "user" and not first_prompt:
325
+ content = d.get("message", {}).get("content")
326
+ if isinstance(content, str) and content.strip():
327
+ first_prompt = content[:200]
328
+ except json.JSONDecodeError:
329
+ continue
330
+ except OSError:
331
+ pass
332
+
333
+ # Quick message count: count lines with "user" or "assistant" type
334
+ # (fast string search, no full JSON parse)
335
+ msg_count = 0
336
+ try:
337
+ with open(jsonl_path, "r", encoding="utf-8", errors="replace") as f:
338
+ for line in f:
339
+ if '"type":"user"' in line or '"type":"assistant"' in line:
340
+ msg_count += 1
341
+ except OSError:
342
+ pass
343
+
344
+ stat = jsonl_path.stat()
345
+ return SessionMeta(
346
+ session_id=session_id,
347
+ summary=custom_title or summary,
348
+ first_prompt=first_prompt,
349
+ message_count=msg_count,
350
+ created="",
351
+ modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
352
+ path=jsonl_path,
353
+ )
354
+
355
+ def list_sessions(self, limit: int = 10) -> list[SessionMeta]:
356
+ """List recent sessions sorted by modification time."""
357
+ files = sorted(
358
+ [f for f in self.project_dir.glob("*.jsonl") if not f.name.startswith("agent-")],
359
+ key=lambda x: x.stat().st_mtime,
360
+ reverse=True,
361
+ )
362
+
363
+ if not files:
364
+ return []
365
+
366
+ results = []
367
+ for f in files[:limit]:
368
+ session_id = f.stem
369
+ meta = self.get_metadata(session_id, f)
370
+ results.append(meta)
371
+
372
+ return results
373
+
374
+
375
+ # ═══════════════════════════════════════════════════════════════════════════════
376
+ # SESSION LOADER
377
+ # ═══════════════════════════════════════════════════════════════════════════════
378
+
379
+
380
+ class Session:
381
+ """Lazy-loaded session with active path extraction and compaction stitching."""
382
+
383
+ def __init__(self, jsonl_path: Path):
384
+ self.path = jsonl_path
385
+ self.session_id = jsonl_path.stem
386
+ self._entries: Optional[list] = None
387
+ self._by_uuid: Optional[dict] = None
388
+ self._children: Optional[dict] = None
389
+ self._entry_positions: Optional[dict] = None # uuid -> file line index
390
+
391
+ def _load(self):
392
+ """Single-pass load of all entries."""
393
+ entries = []
394
+ by_uuid = {}
395
+ positions = {}
396
+ with open(self.path, "r", encoding="utf-8", errors="replace") as f:
397
+ for i, line in enumerate(f):
398
+ line = line.strip()
399
+ if not line:
400
+ continue
401
+ try:
402
+ entry = json.loads(line)
403
+ entry["_line"] = i # track file position
404
+ entries.append(entry)
405
+ uuid = entry.get("uuid")
406
+ if uuid:
407
+ by_uuid[uuid] = entry
408
+ positions[uuid] = i
409
+ except json.JSONDecodeError:
410
+ continue
411
+ self._entries = entries
412
+ self._by_uuid = by_uuid
413
+ self._entry_positions = positions
414
+
415
+ @property
416
+ def entries(self) -> list:
417
+ if self._entries is None:
418
+ self._load()
419
+ return self._entries
420
+
421
+ @property
422
+ def by_uuid(self) -> dict:
423
+ if self._by_uuid is None:
424
+ self._load()
425
+ return self._by_uuid
426
+
427
+ @property
428
+ def entry_positions(self) -> dict:
429
+ if self._entry_positions is None:
430
+ self._load()
431
+ return self._entry_positions
432
+
433
+ @property
434
+ def children(self) -> dict:
435
+ """Children map: parent_uuid -> [child_uuids]. Built on demand."""
436
+ if self._children is None:
437
+ self._children = defaultdict(list)
438
+ for entry in self.entries:
439
+ uuid = entry.get("uuid")
440
+ parent = entry.get("parentUuid")
441
+ if uuid and parent:
442
+ self._children[parent].append(uuid)
443
+ return self._children
444
+
445
+ def active_path(self, stitch: bool = True) -> list[dict]:
446
+ """
447
+ Extract the active conversation path.
448
+
449
+ 1. Find the last entry with a UUID (by file position)
450
+ 2. Walk backward via parentUuid
451
+ 3. At compact_boundary entries, optionally stitch via logicalParentUuid
452
+ 4. Return path in root-to-leaf order
453
+ """
454
+ # Find the last UUID entry that is NOT a sidechain and NOT progress
455
+ last_entry = None
456
+ for entry in reversed(self.entries):
457
+ uuid = entry.get("uuid")
458
+ if not uuid:
459
+ continue
460
+ if entry.get("isSidechain"):
461
+ continue
462
+ last_entry = entry
463
+ break
464
+
465
+ if not last_entry:
466
+ return []
467
+
468
+ # Walk backward
469
+ raw_path = []
470
+ current_uuid = last_entry.get("uuid")
471
+ visited = set()
472
+
473
+ while current_uuid and current_uuid not in visited:
474
+ visited.add(current_uuid)
475
+ entry = self.by_uuid.get(current_uuid)
476
+ if not entry:
477
+ if stitch and raw_path:
478
+ # Broken parent link (e.g., context continuation).
479
+ # Bridge by finding the last UUID entry before the
480
+ # earliest entry in our path so far.
481
+ earliest_line = min(
482
+ e.get("_line", float("inf")) for e in raw_path
483
+ )
484
+ fallback = None
485
+ for e in reversed(self.entries):
486
+ if e.get("_line", 0) >= earliest_line:
487
+ continue
488
+ if e.get("uuid") and e.get("type") != "progress":
489
+ fallback = e
490
+ break
491
+ if fallback and fallback["uuid"] not in visited:
492
+ current_uuid = fallback["uuid"]
493
+ continue
494
+ break
495
+
496
+ raw_path.append(entry)
497
+
498
+ if entry.get("subtype") == "compact_boundary":
499
+ if stitch:
500
+ # Jump to the entry before compaction
501
+ logical_parent = entry.get("logicalParentUuid")
502
+ if logical_parent and logical_parent in self.by_uuid:
503
+ current_uuid = logical_parent
504
+ else:
505
+ # logicalParentUuid target missing — fallback:
506
+ # find the last UUID entry before this compact_boundary
507
+ # in file order (it's part of the pre-compaction tree)
508
+ cb_line = entry.get("_line", float("inf"))
509
+ fallback = None
510
+ for e in reversed(self.entries):
511
+ if e.get("_line", 0) >= cb_line:
512
+ continue
513
+ if e.get("uuid") and e.get("type") != "progress":
514
+ fallback = e
515
+ break
516
+ if fallback:
517
+ current_uuid = fallback["uuid"]
518
+ else:
519
+ break
520
+ else:
521
+ # No stitching, stop at compaction boundary
522
+ break
523
+ else:
524
+ current_uuid = entry.get("parentUuid")
525
+
526
+ raw_path.reverse()
527
+ return raw_path
528
+
529
+ def branch_points(self) -> list[BranchPoint]:
530
+ """
531
+ Find true user-initiated branch points (excluding mechanical fan-out).
532
+
533
+ A true branch is where a parent has multiple children that aren't just:
534
+ - tool_use fan-out (assistant+tool_use -> {next_assistant, tool_result})
535
+ - progress entry forks (progress + tool_result sharing parent)
536
+ """
537
+ active_set = set()
538
+ for entry in self.active_path():
539
+ uuid = entry.get("uuid")
540
+ if uuid:
541
+ active_set.add(uuid)
542
+
543
+ branch_points = []
544
+ checked_parents = set()
545
+
546
+ for entry in self.active_path():
547
+ parent_uuid = entry.get("parentUuid")
548
+ if not parent_uuid or parent_uuid in checked_parents:
549
+ continue
550
+ checked_parents.add(parent_uuid)
551
+
552
+ child_uuids = self.children.get(parent_uuid, [])
553
+ if len(child_uuids) <= 1:
554
+ continue
555
+
556
+ # Check if this is mechanical fan-out
557
+ if self._is_mechanical_fork(parent_uuid, child_uuids):
558
+ continue
559
+
560
+ # Real branch: find alternatives not on active path
561
+ alternatives = [u for u in child_uuids if u not in active_set]
562
+ if not alternatives:
563
+ continue
564
+
565
+ parent_entry = self.by_uuid.get(parent_uuid)
566
+ branch_points.append(BranchPoint(
567
+ parent_uuid=parent_uuid,
568
+ active_child_uuid=entry.get("uuid", ""),
569
+ alternative_uuids=alternatives,
570
+ line_index=parent_entry.get("_line", 0) if parent_entry else 0,
571
+ ))
572
+
573
+ return branch_points
574
+
575
+ def _is_mechanical_fork(self, parent_uuid: str, child_uuids: list) -> bool:
576
+ """
577
+ Returns True if the fork is a mechanical artifact, not a real user branch.
578
+
579
+ Mechanical forks:
580
+ 1. tool_use fan-out: assistant(tool_use) -> {assistant(next block), user(tool_result)}
581
+ 2. progress fork: {progress, user(tool_result)} sharing assistant(tool_use) parent
582
+ 3. Multi-tool fan-out: multiple tool_results sharing same assistant parent
583
+ """
584
+ parent_entry = self.by_uuid.get(parent_uuid)
585
+ if not parent_entry:
586
+ return False
587
+
588
+ parent_type = parent_entry.get("type")
589
+
590
+ # Check child types
591
+ child_types = set()
592
+ child_has_tool_result = False
593
+ child_has_progress = False
594
+ for uuid in child_uuids:
595
+ child = self.by_uuid.get(uuid)
596
+ if not child:
597
+ continue
598
+ ct = child.get("type")
599
+ child_types.add(ct)
600
+
601
+ if ct == "user":
602
+ content = child.get("message", {}).get("content")
603
+ if isinstance(content, list):
604
+ child_has_tool_result = True
605
+ elif ct == "progress":
606
+ child_has_progress = True
607
+
608
+ # Pattern 1: assistant parent with tool_use, children are {assistant, user(tool_result)}
609
+ if parent_type == "assistant":
610
+ blocks = parent_entry.get("message", {}).get("content", [])
611
+ has_tool_use = any(
612
+ isinstance(b, dict) and b.get("type") == "tool_use"
613
+ for b in blocks
614
+ )
615
+ if has_tool_use:
616
+ # Any fork from a tool_use assistant is mechanical
617
+ return True
618
+
619
+ # Pattern 2: progress entries mixed with other children
620
+ if child_has_progress:
621
+ # If all non-progress children are the same, it's mechanical
622
+ non_progress = [u for u in child_uuids
623
+ if self.by_uuid.get(u, {}).get("type") != "progress"]
624
+ if len(non_progress) <= 1:
625
+ return True
626
+
627
+ return False
628
+
629
+
630
+ # ═══════════════════════════════════════════════════════════════════════════════
631
+ # MESSAGE EXTRACTION
632
+ # ═══════════════════════════════════════════════════════════════════════════════
633
+
634
+
635
+ def group_into_turns(raw_path: list[dict], mode: str = "text",
636
+ include_compact_summaries: bool = False) -> list[Turn]:
637
+ """
638
+ Group raw path entries into conversation turns.
639
+
640
+ A turn = one user text message + the full assistant response
641
+ (all text blocks concatenated across consecutive assistant entries).
642
+
643
+ mode='text': user text + assistant text only
644
+ mode='tools': also collect tool call summaries
645
+ mode='raw': not used here (see extract_raw_messages)
646
+ """
647
+ turns = []
648
+ current_turn: Optional[Turn] = None
649
+
650
+ for entry in raw_path:
651
+ entry_type = entry.get("type")
652
+
653
+ # Skip non-conversation entries
654
+ if entry_type in ("progress", "file-history-snapshot",
655
+ "queue-operation", "custom-title", "summary"):
656
+ continue
657
+ if entry_type == "system":
658
+ continue
659
+
660
+ if entry_type == "user":
661
+ msg = entry.get("message", {})
662
+ content = msg.get("content")
663
+
664
+ # Extract text from user messages
665
+ user_text = None
666
+ if isinstance(content, str) and content.strip():
667
+ user_text = content
668
+ elif isinstance(content, list):
669
+ # List content may have text blocks alongside tool_results
670
+ text_parts = []
671
+ for block in content:
672
+ if isinstance(block, dict) and block.get("type") == "text":
673
+ t = block.get("text", "").strip()
674
+ if t:
675
+ text_parts.append(t)
676
+ if text_parts:
677
+ user_text = "\n".join(text_parts)
678
+
679
+ if user_text:
680
+ is_compact = bool(entry.get("isCompactSummary"))
681
+
682
+ # Skip compact summaries unless requested
683
+ if is_compact and not include_compact_summaries:
684
+ continue
685
+
686
+ # Save previous turn
687
+ if current_turn is not None:
688
+ turns.append(current_turn)
689
+
690
+ current_turn = Turn(
691
+ user_text=user_text,
692
+ assistant_text="",
693
+ tool_calls=[],
694
+ timestamp=entry.get("timestamp", ""),
695
+ uuid=entry.get("uuid", ""),
696
+ is_compact_summary=is_compact,
697
+ )
698
+
699
+ elif entry_type == "assistant" and current_turn is not None:
700
+ blocks = entry.get("message", {}).get("content", [])
701
+ for block in blocks:
702
+ if not isinstance(block, dict):
703
+ continue
704
+ btype = block.get("type")
705
+
706
+ if btype == "text":
707
+ text = block.get("text", "")
708
+ if text.strip():
709
+ if current_turn.assistant_text:
710
+ current_turn.assistant_text += "\n" + text
711
+ else:
712
+ current_turn.assistant_text = text
713
+
714
+ elif btype == "tool_use" and mode == "tools":
715
+ current_turn.tool_calls.append(ToolSummary(
716
+ name=block.get("name", "?"),
717
+ input_data=block.get("input", {}),
718
+ ))
719
+
720
+ # Don't forget the last turn
721
+ if current_turn is not None:
722
+ turns.append(current_turn)
723
+
724
+ return turns
725
+
726
+
727
+ def extract_raw_messages(raw_path: list[dict], truncate_len: int = 500) -> list[RawMessage]:
728
+ """
729
+ Extract ALL messages from raw path for --raw mode.
730
+ Includes tool calls, tool results, thinking blocks, system entries.
731
+ """
732
+ do_truncate = truncate_len > 0
733
+ messages = []
734
+
735
+ for entry in raw_path:
736
+ entry_type = entry.get("type")
737
+
738
+ # Skip non-content entries
739
+ if entry_type in ("file-history-snapshot", "queue-operation",
740
+ "custom-title", "summary"):
741
+ continue
742
+ if entry_type == "progress":
743
+ continue
744
+
745
+ if entry_type == "system":
746
+ subtype = entry.get("subtype", "")
747
+ if subtype in ("compact_boundary", "microcompact_boundary"):
748
+ content = entry.get("content", "")
749
+ meta = ""
750
+ if subtype == "compact_boundary":
751
+ cm = entry.get("compactMetadata", {})
752
+ meta = f" (trigger={cm.get('trigger', '?')}, preTokens={cm.get('preTokens', '?')})"
753
+ elif subtype == "microcompact_boundary":
754
+ cm = entry.get("microcompactMetadata", {})
755
+ meta = f" (trigger={cm.get('trigger', '?')}, saved={cm.get('tokensSaved', '?')} tokens)"
756
+ messages.append(RawMessage(
757
+ role=f"system ({subtype})",
758
+ content=f"{content}{meta}",
759
+ timestamp=entry.get("timestamp", ""),
760
+ uuid=entry.get("uuid", ""),
761
+ entry_type=entry_type,
762
+ ))
763
+ continue
764
+
765
+ if entry_type == "user":
766
+ msg = entry.get("message", {})
767
+ content = msg.get("content")
768
+
769
+ if isinstance(content, str):
770
+ if content.strip():
771
+ role = "user"
772
+ if entry.get("isCompactSummary"):
773
+ role = "user (compact_summary)"
774
+ messages.append(RawMessage(
775
+ role=role,
776
+ content=content,
777
+ timestamp=entry.get("timestamp", ""),
778
+ uuid=entry.get("uuid", ""),
779
+ entry_type=entry_type,
780
+ ))
781
+ elif isinstance(content, list):
782
+ # Tool result
783
+ parts = []
784
+ for item in content:
785
+ if isinstance(item, dict) and item.get("type") == "tool_result":
786
+ tool_id = item.get("tool_use_id", "?")[:16]
787
+ result_content = ""
788
+ rc = item.get("content")
789
+ if isinstance(rc, str):
790
+ result_content = rc
791
+ elif isinstance(rc, list):
792
+ for sub in rc:
793
+ if isinstance(sub, dict) and sub.get("type") == "text":
794
+ result_content += sub.get("text", "")
795
+ if do_truncate and len(result_content) > truncate_len:
796
+ result_content = result_content[:truncate_len] + "..."
797
+ is_err = item.get("is_error", False)
798
+ err_marker = " ERROR" if is_err else ""
799
+ parts.append(f"[tool_result {tool_id}{err_marker}]\n{result_content}")
800
+ if parts:
801
+ messages.append(RawMessage(
802
+ role="user (tool_result)",
803
+ content="\n".join(parts),
804
+ timestamp=entry.get("timestamp", ""),
805
+ uuid=entry.get("uuid", ""),
806
+ entry_type=entry_type,
807
+ ))
808
+
809
+ elif entry_type == "assistant":
810
+ blocks = entry.get("message", {}).get("content", [])
811
+ parts = []
812
+ has_tool = False
813
+
814
+ for block in blocks:
815
+ if not isinstance(block, dict):
816
+ continue
817
+ btype = block.get("type")
818
+
819
+ if btype == "text":
820
+ text = block.get("text", "")
821
+ if text.strip():
822
+ parts.append(text)
823
+
824
+ elif btype == "tool_use":
825
+ has_tool = True
826
+ name = block.get("name", "?")
827
+ tool_id = block.get("id", "")[:16]
828
+ inp = json.dumps(block.get("input", {}), indent=2)
829
+ tool_input_len = max(100, truncate_len * 3 // 5) if do_truncate else 0
830
+ if do_truncate and len(inp) > tool_input_len:
831
+ inp = inp[:tool_input_len] + "..."
832
+ parts.append(f"[tool_use: {name} ({tool_id})]\n{inp}")
833
+
834
+ elif btype == "thinking":
835
+ thinking = block.get("thinking", "")
836
+ if thinking.strip():
837
+ thinking_len = max(100, truncate_len * 2 // 5) if do_truncate else 0
838
+ if do_truncate and len(thinking) > thinking_len:
839
+ thinking = thinking[:thinking_len] + "..."
840
+ parts.append(f"[thinking]\n{thinking}")
841
+
842
+ if parts:
843
+ role = "assistant (tool)" if has_tool else "assistant"
844
+ messages.append(RawMessage(
845
+ role=role,
846
+ content="\n\n".join(parts),
847
+ timestamp=entry.get("timestamp", ""),
848
+ uuid=entry.get("uuid", ""),
849
+ entry_type=entry_type,
850
+ ))
851
+
852
+ return messages
853
+
854
+
855
+ # ═══════════════════════════════════════════════════════════════════════════════
856
+ # OUTPUT FORMATTING
857
+ # ═══════════════════════════════════════════════════════════════════════════════
858
+
859
+
860
+ def format_turn(turn: Turn, index: int, total: int,
861
+ show_tools: bool = False, show_timestamp: bool = False) -> str:
862
+ """Format a Turn for display."""
863
+ lines = []
864
+
865
+ # User message
866
+ header = f"[{index}/{total}]"
867
+ if show_timestamp and turn.timestamp:
868
+ ts = _parse_timestamp(turn.timestamp)
869
+ if ts != datetime.min:
870
+ header += f" {ts.strftime('%H:%M:%S')}"
871
+ lines.append(f"{header} USER")
872
+ lines.append("─" * 60)
873
+ if turn.is_compact_summary:
874
+ lines.append("[Compaction Summary]")
875
+ lines.append(turn.user_text)
876
+ lines.append("")
877
+
878
+ # Tool calls (if --tools mode)
879
+ if show_tools and turn.tool_calls:
880
+ for tc in turn.tool_calls:
881
+ lines.append(f" > {tc.one_line()}")
882
+ lines.append("")
883
+
884
+ # Assistant response
885
+ if turn.assistant_text:
886
+ lines.append(f"[{index}/{total}] ASSISTANT")
887
+ if show_tools and turn.tool_calls:
888
+ lines.append(f"({len(turn.tool_calls)} tool calls)")
889
+ lines.append("─" * 60)
890
+ lines.append(turn.assistant_text)
891
+ lines.append("")
892
+
893
+ return "\n".join(lines)
894
+
895
+
896
+ def format_raw_message(msg: RawMessage, index: int, total: int,
897
+ show_timestamp: bool = True) -> str:
898
+ """Format a RawMessage for display."""
899
+ header = f"[{index}/{total}] {msg.role.upper()}"
900
+ if show_timestamp and msg.timestamp:
901
+ ts = _parse_timestamp(msg.timestamp)
902
+ if ts != datetime.min:
903
+ header += f" ({ts.strftime('%H:%M:%S')})"
904
+ if msg.uuid:
905
+ header += f" uuid={msg.uuid[:12]}"
906
+
907
+ return f"{header}\n{'─' * 60}\n{msg.content}\n"
908
+
909
+
910
+ def format_turns_json(turns: list[Turn], session_id: str, total: int,
911
+ start_index: int) -> str:
912
+ """Format turns as JSON."""
913
+ result = {
914
+ "session_id": session_id,
915
+ "total_turns": total,
916
+ "turns": [],
917
+ }
918
+ for i, turn in enumerate(turns):
919
+ t = {
920
+ "index": start_index + i,
921
+ "user": {
922
+ "text": turn.user_text,
923
+ "uuid": turn.uuid,
924
+ "timestamp": turn.timestamp,
925
+ "is_compact_summary": turn.is_compact_summary,
926
+ },
927
+ "assistant": {
928
+ "text": turn.assistant_text,
929
+ },
930
+ }
931
+ if turn.tool_calls:
932
+ t["assistant"]["tool_calls"] = [
933
+ {"name": tc.name, "summary": tc.one_line()}
934
+ for tc in turn.tool_calls
935
+ ]
936
+ result["turns"].append(t)
937
+ return json.dumps(result, indent=2, ensure_ascii=False)
938
+
939
+
940
+ def format_raw_json(messages: list[RawMessage], session_id: str) -> str:
941
+ """Format raw messages as JSON."""
942
+ result = {
943
+ "session_id": session_id,
944
+ "total_messages": len(messages),
945
+ "messages": [
946
+ {
947
+ "role": msg.role,
948
+ "content": msg.content,
949
+ "uuid": msg.uuid,
950
+ "timestamp": msg.timestamp,
951
+ }
952
+ for msg in messages
953
+ ],
954
+ }
955
+ return json.dumps(result, indent=2, ensure_ascii=False)
956
+
957
+
958
+ # ═══════════════════════════════════════════════════════════════════════════════
959
+ # CLIPBOARD
960
+ # ═══════════════════════════════════════════════════════════════════════════════
961
+
962
+
963
+ def copy_to_clipboard(text: str) -> bool:
964
+ """Copy text to clipboard using clip.exe (WSL)."""
965
+ try:
966
+ process = subprocess.Popen(
967
+ ["clip.exe"],
968
+ stdin=subprocess.PIPE,
969
+ shell=False,
970
+ )
971
+ process.communicate(input=text.encode("utf-16-le"))
972
+ return process.returncode == 0
973
+ except Exception as e:
974
+ print(f"Error copying to clipboard: {e}", file=sys.stderr)
975
+ return False
976
+
977
+
978
+ # ═══════════════════════════════════════════════════════════════════════════════
979
+ # SESSION RESOLUTION HELPERS
980
+ # ═══════════════════════════════════════════════════════════════════════════════
981
+
982
+
983
+ def resolve_session(project_dir: Path, session_arg: Optional[str]) -> Path:
984
+ """Resolve session argument to a JSONL file path."""
985
+ if session_arg is None:
986
+ # Latest session by mtime
987
+ files = sorted(
988
+ [f for f in project_dir.glob("*.jsonl") if not f.name.startswith("agent-")],
989
+ key=lambda x: x.stat().st_mtime,
990
+ reverse=True,
991
+ )
992
+ if not files:
993
+ print("No sessions found.", file=sys.stderr)
994
+ sys.exit(1)
995
+ return files[0]
996
+
997
+ # Numeric index: Nth from list
998
+ if session_arg.isdigit():
999
+ idx = int(session_arg)
1000
+ index = SessionIndex(project_dir)
1001
+ sessions = index.list_sessions(idx)
1002
+ if idx <= len(sessions):
1003
+ return sessions[idx - 1].path
1004
+ else:
1005
+ print(f"Error: Session index {session_arg} out of range (have {len(sessions)})", file=sys.stderr)
1006
+ sys.exit(1)
1007
+
1008
+ # UUID prefix match
1009
+ matches = list(project_dir.glob(f"{session_arg}*.jsonl"))
1010
+ matches = [m for m in matches if not m.name.startswith("agent-")]
1011
+ if matches:
1012
+ if len(matches) == 1:
1013
+ return matches[0]
1014
+ else:
1015
+ print(f"Ambiguous session prefix '{session_arg}', matches:", file=sys.stderr)
1016
+ for m in matches[:5]:
1017
+ print(f" {m.stem}", file=sys.stderr)
1018
+ sys.exit(1)
1019
+
1020
+ print(f"Error: Session '{session_arg}' not found.", file=sys.stderr)
1021
+ print("Use 'cchat list' to see available sessions.", file=sys.stderr)
1022
+ sys.exit(1)
1023
+
1024
+
1025
+ def parse_range(range_str: str, max_val: int) -> list[int]:
1026
+ """Parse a range string like '3', '3-7', '-1', '-3--1' into 1-based indices."""
1027
+ indices = []
1028
+
1029
+ # Handle negative-to-negative range: -3--1
1030
+ m = re.match(r"^(-?\d+)--(-?\d+)$", range_str)
1031
+ if m:
1032
+ start, end = int(m.group(1)), -int(m.group(2))
1033
+ else:
1034
+ m = re.match(r"^(-?\d+)-(\d+)$", range_str)
1035
+ if m:
1036
+ start, end = int(m.group(1)), int(m.group(2))
1037
+ elif range_str.lstrip("-").isdigit():
1038
+ val = int(range_str)
1039
+ if val < 0:
1040
+ idx = max_val + val + 1
1041
+ return [idx] if 1 <= idx <= max_val else []
1042
+ else:
1043
+ return [val] if 1 <= val <= max_val else []
1044
+ else:
1045
+ print(f"Error: Invalid range '{range_str}'", file=sys.stderr)
1046
+ return []
1047
+
1048
+ # Resolve negatives
1049
+ if start < 0:
1050
+ start = max_val + start + 1
1051
+ if end < 0:
1052
+ end = max_val + end + 1
1053
+
1054
+ return [i for i in range(start, end + 1) if 1 <= i <= max_val]
1055
+
1056
+
1057
+ def compute_indices(total: int, n: Optional[int], range_str: Optional[str],
1058
+ show_all: bool) -> list[int]:
1059
+ """Compute which turn indices to show."""
1060
+ if show_all:
1061
+ return list(range(1, total + 1))
1062
+ elif range_str:
1063
+ return parse_range(range_str, total)
1064
+ elif n:
1065
+ start = max(0, total - n)
1066
+ return list(range(start + 1, total + 1))
1067
+ else:
1068
+ start = max(0, total - DEFAULT_TURNS)
1069
+ return list(range(start + 1, total + 1))
1070
+
1071
+
1072
+ # ═══════════════════════════════════════════════════════════════════════════════
1073
+ # COMMANDS
1074
+ # ═══════════════════════════════════════════════════════════════════════════════
1075
+
1076
+
1077
+ def cmd_list(args):
1078
+ """List recent sessions."""
1079
+ project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
1080
+ index = SessionIndex(project_dir)
1081
+ sessions = index.list_sessions(args.count)
1082
+
1083
+ if not sessions:
1084
+ print("No sessions found.")
1085
+ return
1086
+
1087
+ print(f"Sessions in {project_dir.name}:\n")
1088
+ for i, s in enumerate(sessions, 1):
1089
+ modified = ""
1090
+ if s.modified:
1091
+ ts = _parse_timestamp(s.modified)
1092
+ if ts != datetime.min:
1093
+ modified = ts.strftime("%Y-%m-%d %H:%M")
1094
+ else:
1095
+ modified = s.modified[:16]
1096
+ msg_info = f"{s.message_count} msgs"
1097
+ print(f"[{i}] {s.session_id[:8]}... ({msg_info}, {modified})")
1098
+
1099
+ display = s.summary or s.first_prompt
1100
+ if display:
1101
+ # Clean up and truncate
1102
+ display = display.replace("\n", " ").strip()
1103
+ if len(display) > 76:
1104
+ display = display[:76] + "..."
1105
+ print(f" {display}")
1106
+ print()
1107
+
1108
+
1109
+ def cmd_view(args):
1110
+ """View messages from a session."""
1111
+ project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
1112
+ session_file = resolve_session(project_dir, args.session)
1113
+ session = Session(session_file)
1114
+
1115
+ raw_path = session.active_path(stitch=not args.no_stitch)
1116
+ if not raw_path:
1117
+ print("No messages in this session.")
1118
+ return
1119
+
1120
+ if args.raw:
1121
+ # Raw mode
1122
+ messages = extract_raw_messages(raw_path, truncate_len=args.truncate)
1123
+ total = len(messages)
1124
+ indices = compute_indices(total, args.n, args.r, args.all)
1125
+
1126
+ if not indices:
1127
+ print(f"No messages match (1-{total} available)", file=sys.stderr)
1128
+ sys.exit(1)
1129
+
1130
+ if args.json:
1131
+ selected = [messages[i - 1] for i in indices]
1132
+ print(format_raw_json(selected, session.session_id))
1133
+ else:
1134
+ print(f"Session: {session.session_id}")
1135
+ print(f"Showing {len(indices)} of {total} raw messages")
1136
+ print("=" * 60)
1137
+ for i in indices:
1138
+ print(format_raw_message(messages[i - 1], i, total))
1139
+ else:
1140
+ # Turn mode
1141
+ mode = "tools" if args.tools else "text"
1142
+ turns = group_into_turns(raw_path, mode=mode,
1143
+ include_compact_summaries=args.compact_summaries)
1144
+
1145
+ # Fallback: if no turns found and compact summaries were hidden,
1146
+ # retry with them included (handles sessions where the only user
1147
+ # text is the continuation summary after compaction)
1148
+ if not turns and not args.compact_summaries:
1149
+ turns = group_into_turns(raw_path, mode=mode,
1150
+ include_compact_summaries=True)
1151
+
1152
+ total = len(turns)
1153
+
1154
+ if total == 0:
1155
+ print("No conversation turns in this session.")
1156
+ return
1157
+
1158
+ indices = compute_indices(total, args.n, args.r, args.all)
1159
+
1160
+ if not indices:
1161
+ print(f"No turns match (1-{total} available)", file=sys.stderr)
1162
+ sys.exit(1)
1163
+
1164
+ if args.json:
1165
+ selected = [turns[i - 1] for i in indices]
1166
+ print(format_turns_json(selected, session.session_id, total, indices[0]))
1167
+ else:
1168
+ print(f"Session: {session.session_id}")
1169
+ print(f"Showing {len(indices)} of {total} turns")
1170
+ print("=" * 60)
1171
+ for i in indices:
1172
+ print(format_turn(turns[i - 1], i, total,
1173
+ show_tools=args.tools,
1174
+ show_timestamp=args.timestamps))
1175
+
1176
+
1177
+ def cmd_copy(args):
1178
+ """Copy message(s) to clipboard."""
1179
+ project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
1180
+ session_file = resolve_session(project_dir, args.session)
1181
+ session = Session(session_file)
1182
+
1183
+ raw_path = session.active_path(stitch=True)
1184
+ if not raw_path:
1185
+ print("No messages in this session.", file=sys.stderr)
1186
+ sys.exit(1)
1187
+
1188
+ if args.raw:
1189
+ messages = extract_raw_messages(raw_path, truncate_len=-1)
1190
+ total = len(messages)
1191
+
1192
+ # Default: last message
1193
+ if args.r is None and args.n is None:
1194
+ args.r = "-1"
1195
+
1196
+ indices = compute_indices(total, args.n, args.r, False)
1197
+ if not indices:
1198
+ print(f"No messages match (1-{total} available)", file=sys.stderr)
1199
+ sys.exit(1)
1200
+
1201
+ texts = []
1202
+ for i in indices:
1203
+ msg = messages[i - 1]
1204
+ texts.append(f"**{msg.role.title()}:**\n\n{msg.content}")
1205
+ combined = "\n\n---\n\n".join(texts)
1206
+ else:
1207
+ mode = "tools" if args.tools else "text"
1208
+ turns = group_into_turns(raw_path, mode=mode)
1209
+ if not turns:
1210
+ turns = group_into_turns(raw_path, mode=mode,
1211
+ include_compact_summaries=True)
1212
+ total = len(turns)
1213
+
1214
+ # Default: last turn's assistant response
1215
+ if args.r is None and args.n is None:
1216
+ args.r = "-1"
1217
+
1218
+ indices = compute_indices(total, args.n, args.r, False)
1219
+ if not indices:
1220
+ print(f"No turns match (1-{total} available)", file=sys.stderr)
1221
+ sys.exit(1)
1222
+
1223
+ texts = []
1224
+ for i in indices:
1225
+ turn = turns[i - 1]
1226
+ parts = []
1227
+ if turn.user_text:
1228
+ parts.append(f"**User:**\n\n{turn.user_text}")
1229
+ if turn.assistant_text:
1230
+ parts.append(f"**Assistant:**\n\n{turn.assistant_text}")
1231
+ texts.append("\n\n".join(parts))
1232
+ combined = "\n\n---\n\n".join(texts)
1233
+
1234
+ if copy_to_clipboard(combined):
1235
+ if len(indices) == 1:
1236
+ print(f"Copied turn #{indices[0]} to clipboard ({len(combined)} chars)")
1237
+ else:
1238
+ print(f"Copied {len(indices)} turns (#{indices[0]}-#{indices[-1]}) to clipboard ({len(combined)} chars)")
1239
+ else:
1240
+ print("Failed to copy to clipboard", file=sys.stderr)
1241
+ sys.exit(1)
1242
+
1243
+
1244
+ def cmd_projects(args):
1245
+ """List all projects."""
1246
+ projects = ProjectResolver.list_all_projects()
1247
+
1248
+ if not projects:
1249
+ print("No projects found.")
1250
+ return
1251
+
1252
+ print(f"Projects ({len(projects)}):\n")
1253
+ for i, p in enumerate(projects, 1):
1254
+ modified = p["latest_modified"].strftime("%Y-%m-%d %H:%M")
1255
+ print(f"[{i}] {p['decoded_path']}")
1256
+ print(f" {p['session_count']} sessions, last active: {modified}")
1257
+ print(f" key: {p['name']}")
1258
+ print()
1259
+
1260
+
1261
+ def cmd_search(args):
1262
+ """Search across sessions for a pattern."""
1263
+ project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
1264
+ pattern = args.pattern
1265
+ limit = args.limit
1266
+
1267
+ files = sorted(
1268
+ [f for f in project_dir.glob("*.jsonl") if not f.name.startswith("agent-")],
1269
+ key=lambda x: x.stat().st_mtime,
1270
+ reverse=True,
1271
+ )
1272
+
1273
+ if not files:
1274
+ print("No sessions to search.")
1275
+ return
1276
+
1277
+ pattern_lower = pattern.lower()
1278
+ results = []
1279
+
1280
+ for f in files:
1281
+ if len(results) >= limit:
1282
+ break
1283
+ try:
1284
+ with open(f, "r", encoding="utf-8", errors="replace") as fp:
1285
+ for line_num, line in enumerate(fp):
1286
+ if len(results) >= limit:
1287
+ break
1288
+ if pattern_lower not in line.lower():
1289
+ continue
1290
+ try:
1291
+ entry = json.loads(line)
1292
+ entry_type = entry.get("type")
1293
+ if entry_type not in ("user", "assistant"):
1294
+ continue
1295
+ msg = entry.get("message", {})
1296
+ content = msg.get("content")
1297
+
1298
+ # Extract searchable text
1299
+ text = ""
1300
+ if isinstance(content, str):
1301
+ text = content
1302
+ elif isinstance(content, list):
1303
+ for block in content:
1304
+ if isinstance(block, dict) and block.get("type") == "text":
1305
+ text += block.get("text", "")
1306
+
1307
+ if pattern_lower in text.lower():
1308
+ # Find the match context
1309
+ idx = text.lower().index(pattern_lower)
1310
+ start = max(0, idx - 40)
1311
+ end = min(len(text), idx + len(pattern) + 40)
1312
+ snippet = text[start:end].replace("\n", " ")
1313
+ if start > 0:
1314
+ snippet = "..." + snippet
1315
+ if end < len(text):
1316
+ snippet = snippet + "..."
1317
+
1318
+ results.append({
1319
+ "session_id": f.stem,
1320
+ "role": entry_type,
1321
+ "snippet": snippet,
1322
+ "timestamp": entry.get("timestamp", ""),
1323
+ "file": f,
1324
+ })
1325
+ except json.JSONDecodeError:
1326
+ continue
1327
+ except OSError:
1328
+ continue
1329
+
1330
+ if not results:
1331
+ print(f"No matches for '{pattern}'.")
1332
+ return
1333
+
1334
+ print(f"Found {len(results)} match{'es' if len(results) != 1 else ''} for '{pattern}':\n")
1335
+ for i, r in enumerate(results, 1):
1336
+ ts = ""
1337
+ if r["timestamp"]:
1338
+ parsed = _parse_timestamp(r["timestamp"])
1339
+ if parsed != datetime.min:
1340
+ ts = parsed.strftime("%Y-%m-%d %H:%M")
1341
+ print(f"[{i}] {r['session_id'][:8]}... ({r['role']}, {ts})")
1342
+ print(f" {r['snippet']}")
1343
+ print()
1344
+
1345
+
1346
+ def cmd_tree(args):
1347
+ """Show conversation tree structure."""
1348
+ project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
1349
+ session_file = resolve_session(project_dir, args.session)
1350
+ session = Session(session_file)
1351
+
1352
+ raw_path = session.active_path(stitch=True)
1353
+ if not raw_path:
1354
+ print("No messages in this session.")
1355
+ return
1356
+
1357
+ turns = group_into_turns(raw_path, mode="text")
1358
+ branch_points = session.branch_points()
1359
+
1360
+ # Build a set of UUIDs that are branch parents
1361
+ branch_parent_uuids = {bp.parent_uuid for bp in branch_points}
1362
+
1363
+ print(f"Session: {session.session_id}")
1364
+ print(f"Turns: {len(turns)}, Branch points: {len(branch_points)}")
1365
+ print("=" * 60)
1366
+
1367
+ # Show turns with branch markers
1368
+ for i, turn in enumerate(turns, 1):
1369
+ prefix = "├──" if i < len(turns) else "└──"
1370
+ user_preview = turn.user_text.replace("\n", " ")[:60]
1371
+ if len(turn.user_text) > 60:
1372
+ user_preview += "..."
1373
+
1374
+ print(f"{prefix} [{i}] User: {user_preview}")
1375
+
1376
+ if turn.assistant_text:
1377
+ asst_preview = turn.assistant_text.replace("\n", " ")[:60]
1378
+ if len(turn.assistant_text) > 60:
1379
+ asst_preview += "..."
1380
+ indent = "│ " if i < len(turns) else " "
1381
+ print(f"{indent} Assistant: {asst_preview}")
1382
+
1383
+ if turn.tool_calls:
1384
+ print(f"{indent} ({len(turn.tool_calls)} tool calls)")
1385
+
1386
+ if branch_points:
1387
+ print(f"\nBranch Points ({len(branch_points)}):")
1388
+ print("─" * 40)
1389
+ for bp in branch_points:
1390
+ parent_entry = session.by_uuid.get(bp.parent_uuid, {})
1391
+ parent_type = parent_entry.get("type", "?")
1392
+ n_alts = len(bp.alternative_uuids)
1393
+ print(f" At {bp.parent_uuid[:12]}... ({parent_type}): "
1394
+ f"{n_alts} alternative{'s' if n_alts != 1 else ''}")
1395
+
1396
+
1397
+ def cmd_export(args):
1398
+ """Export full session."""
1399
+ project_dir = ProjectResolver.get_project_dir_or_exit(args.project)
1400
+ session_file = resolve_session(project_dir, args.session)
1401
+ session = Session(session_file)
1402
+
1403
+ raw_path = session.active_path(stitch=True)
1404
+ if not raw_path:
1405
+ print("No messages in this session.")
1406
+ return
1407
+
1408
+ if args.json:
1409
+ if args.raw:
1410
+ messages = extract_raw_messages(raw_path, truncate_len=-1)
1411
+ print(format_raw_json(messages, session.session_id))
1412
+ else:
1413
+ mode = "tools" if args.include_tools else "text"
1414
+ turns = group_into_turns(raw_path, mode=mode,
1415
+ include_compact_summaries=True)
1416
+ print(format_turns_json(turns, session.session_id, len(turns), 1))
1417
+ else:
1418
+ # Markdown export
1419
+ mode = "tools" if args.include_tools else "text"
1420
+ turns = group_into_turns(raw_path, mode=mode,
1421
+ include_compact_summaries=True)
1422
+ if not turns:
1423
+ print("No conversation turns.")
1424
+ return
1425
+
1426
+ print(f"# Session {session.session_id}")
1427
+ print(f"**Turns:** {len(turns)}")
1428
+ print()
1429
+ for i, turn in enumerate(turns, 1):
1430
+ print(format_turn(turn, i, len(turns),
1431
+ show_tools=args.include_tools,
1432
+ show_timestamp=True))
1433
+
1434
+
1435
+ # ═══════════════════════════════════════════════════════════════════════════════
1436
+ # CLI PARSER
1437
+ # ═══════════════════════════════════════════════════════════════════════════════
1438
+
1439
+
1440
+ def _add_project_arg(p):
1441
+ """Add --project/-p to a subparser."""
1442
+ p.add_argument("--project", "-p", metavar="PATH",
1443
+ help="Use project at PATH instead of cwd")
1444
+ return p
1445
+
1446
+
1447
+ def build_parser() -> argparse.ArgumentParser:
1448
+ parser = argparse.ArgumentParser(
1449
+ prog="cchat",
1450
+ description="Claude Code Chat History Browser - Browse, search, and copy messages",
1451
+ )
1452
+
1453
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
1454
+
1455
+ # list
1456
+ list_p = subparsers.add_parser("list", aliases=["ls"],
1457
+ help="List recent sessions")
1458
+ _add_project_arg(list_p)
1459
+ list_p.add_argument("count", nargs="?", type=int, default=10,
1460
+ help="Number of sessions (default: 10)")
1461
+
1462
+ # view
1463
+ view_p = subparsers.add_parser("view", aliases=["v"],
1464
+ help="View conversation messages")
1465
+ _add_project_arg(view_p)
1466
+ view_p.add_argument("session", nargs="?",
1467
+ help="Session index or UUID prefix (default: latest)")
1468
+ view_p.add_argument("-n", type=int, metavar="N",
1469
+ help="Show last N turns")
1470
+ view_p.add_argument("-r", metavar="RANGE",
1471
+ help="Show specific turns: 10, 5-10, -1, -3--1")
1472
+ view_p.add_argument("--all", action="store_true",
1473
+ help="Show all turns")
1474
+ view_p.add_argument("--tools", action="store_true",
1475
+ help="Show tool call summaries")
1476
+ view_p.add_argument("--raw", action="store_true",
1477
+ help="Show everything (tool IO, thinking, system)")
1478
+ view_p.add_argument("--json", action="store_true",
1479
+ help="Output as JSON")
1480
+ view_p.add_argument("--no-stitch", action="store_true",
1481
+ help="Don't bridge compaction boundaries")
1482
+ view_p.add_argument("--timestamps", action="store_true",
1483
+ help="Show timestamps")
1484
+ view_p.add_argument("--compact-summaries", action="store_true",
1485
+ help="Include compaction summary messages")
1486
+ view_p.add_argument("--truncate", type=int, default=500, metavar="LEN",
1487
+ help="Truncate length for raw content (default: 500, -1=none)")
1488
+
1489
+ # copy
1490
+ copy_p = subparsers.add_parser("copy", aliases=["cp"],
1491
+ help="Copy messages to clipboard")
1492
+ _add_project_arg(copy_p)
1493
+ copy_p.add_argument("session", nargs="?",
1494
+ help="Session index or UUID prefix")
1495
+ copy_p.add_argument("-n", type=int, metavar="N",
1496
+ help="Copy last N turns")
1497
+ copy_p.add_argument("-r", metavar="RANGE",
1498
+ help="Copy specific turns (default: -1)")
1499
+ copy_p.add_argument("--tools", action="store_true",
1500
+ help="Include tool summaries")
1501
+ copy_p.add_argument("--raw", action="store_true",
1502
+ help="Copy raw messages")
1503
+
1504
+ # projects (no --project flag needed)
1505
+ subparsers.add_parser("projects", help="List all projects")
1506
+
1507
+ # search
1508
+ search_p = subparsers.add_parser("search", aliases=["s"],
1509
+ help="Search across sessions")
1510
+ _add_project_arg(search_p)
1511
+ search_p.add_argument("pattern", help="Search pattern")
1512
+ search_p.add_argument("--limit", type=int, default=20,
1513
+ help="Max results (default: 20)")
1514
+
1515
+ # tree
1516
+ tree_p = subparsers.add_parser("tree", help="Show conversation tree structure")
1517
+ _add_project_arg(tree_p)
1518
+ tree_p.add_argument("session", nargs="?",
1519
+ help="Session index or UUID prefix")
1520
+
1521
+ # export
1522
+ export_p = subparsers.add_parser("export", help="Export full session")
1523
+ _add_project_arg(export_p)
1524
+ export_p.add_argument("session", nargs="?",
1525
+ help="Session index or UUID prefix")
1526
+ export_p.add_argument("--json", action="store_true",
1527
+ help="Export as JSON (default: markdown)")
1528
+ export_p.add_argument("--raw", action="store_true",
1529
+ help="Export raw messages")
1530
+ export_p.add_argument("--include-tools", action="store_true",
1531
+ help="Include tool calls in export")
1532
+
1533
+ return parser
1534
+
1535
+
1536
+ def _preprocess_argv(argv: list[str]) -> list[str]:
1537
+ """Fix argparse issue with -r and negative ranges like -3--1.
1538
+
1539
+ argparse can't handle '-r -3--1' because '-3--1' starts with '-'
1540
+ and isn't a valid negative number, so argparse rejects it.
1541
+ We normalize '-r <range>' to '-r=<range>' when the range looks valid.
1542
+ """
1543
+ result = []
1544
+ i = 0
1545
+ range_pat = re.compile(r'^-?\d+(--?\d+)?$')
1546
+ while i < len(argv):
1547
+ if argv[i] == '-r' and i + 1 < len(argv) and range_pat.match(argv[i + 1]):
1548
+ result.append(f'-r={argv[i + 1]}')
1549
+ i += 2
1550
+ else:
1551
+ result.append(argv[i])
1552
+ i += 1
1553
+ return result
1554
+
1555
+
1556
+ def main():
1557
+ parser = build_parser()
1558
+ args = parser.parse_args(_preprocess_argv(sys.argv[1:]))
1559
+
1560
+ # Default command: view
1561
+ if args.command is None:
1562
+ # Show help if no command
1563
+ parser.print_help()
1564
+ sys.exit(0)
1565
+
1566
+ commands = {
1567
+ "list": cmd_list,
1568
+ "ls": cmd_list,
1569
+ "view": cmd_view,
1570
+ "v": cmd_view,
1571
+ "copy": cmd_copy,
1572
+ "cp": cmd_copy,
1573
+ "projects": cmd_projects,
1574
+ "search": cmd_search,
1575
+ "s": cmd_search,
1576
+ "tree": cmd_tree,
1577
+ "export": cmd_export,
1578
+ }
1579
+
1580
+ cmd = commands.get(args.command)
1581
+ if cmd:
1582
+ cmd(args)
1583
+ else:
1584
+ parser.print_help()
1585
+
1586
+
1587
+ if __name__ == "__main__":
1588
+ main()