cchat 0.1.0__tar.gz → 1.0.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cchat
3
- Version: 0.1.0
3
+ Version: 1.0.0
4
4
  Summary: Browse and search Claude Code conversation history from the terminal.
5
5
  Author: asparagusbeef
6
6
  Requires-Python: >=3.8
@@ -12,11 +12,20 @@ Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Software Development
14
14
  License-File: LICENSE
15
+ Requires-Dist: pytest>=7.0 ; extra == "test"
16
+ Requires-Dist: pytest-cov>=4.0 ; extra == "test"
15
17
  Project-URL: Homepage, https://github.com/asparagusbeef/cchat
16
18
  Project-URL: Issues, https://github.com/asparagusbeef/cchat/issues
19
+ Provides-Extra: test
17
20
 
18
21
  # cchat
19
22
 
23
+ [![Tests](https://github.com/asparagusbeef/cchat/actions/workflows/test.yml/badge.svg)](https://github.com/asparagusbeef/cchat/actions/workflows/test.yml)
24
+ [![codecov](https://codecov.io/gh/asparagusbeef/cchat/graph/badge.svg)](https://codecov.io/gh/asparagusbeef/cchat)
25
+ [![PyPI](https://img.shields.io/pypi/v/cchat)](https://pypi.org/project/cchat/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/cchat)](https://pypi.org/project/cchat/)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
28
+
20
29
  Browse and search Claude Code conversation history from the terminal.
21
30
 
22
31
  `cchat` reads the JSONL conversation logs that Claude Code stores in `~/.claude/projects/` and presents them as readable conversation turns. It handles compaction stitching, branch detection, and the full UUID tree structure so you don't have to parse raw JSONL yourself.
@@ -121,6 +130,48 @@ cchat:
121
130
  - No dependencies (stdlib only)
122
131
  - Clipboard copy uses `clip.exe` (WSL) — other platforms not yet supported
123
132
 
133
+ ## Contributing
134
+
135
+ ### Setup
136
+
137
+ ```bash
138
+ git clone https://github.com/asparagusbeef/cchat.git
139
+ cd cchat
140
+ pip install -e ".[test]"
141
+ ```
142
+
143
+ ### Running tests
144
+
145
+ ```bash
146
+ pytest -v # full suite
147
+ pytest tests/test_session.py -v # single module
148
+ pytest --cov=cchat --cov-report=term # with coverage
149
+ ```
150
+
151
+ ### Project structure
152
+
153
+ ```
154
+ cchat.py # entire application (single-file)
155
+ tests/
156
+ conftest.py # shared fixtures, mock project dirs
157
+ fixtures/ # synthetic JSONL sessions matching real Claude Code format
158
+ test_utils.py
159
+ test_formatting.py
160
+ test_session.py
161
+ test_message_extraction.py
162
+ test_project_resolver.py
163
+ test_session_index.py
164
+ test_cli.py
165
+ ```
166
+
167
+ Test fixtures are synthetic JSONL files that match the real Claude Code session format (streaming chunks, UUID trees, compaction boundaries). See `tests/fixtures/` for examples of the expected data shape.
168
+
169
+ ### Guidelines
170
+
171
+ - Keep it as a single Python file with zero runtime dependencies.
172
+ - Tests go in `tests/`. Fixtures go in `tests/fixtures/`.
173
+ - Run the full test suite before opening a PR.
174
+
124
175
  ## License
125
176
 
126
177
  MIT
@@ -1,5 +1,11 @@
1
1
  # cchat
2
2
 
3
+ [![Tests](https://github.com/asparagusbeef/cchat/actions/workflows/test.yml/badge.svg)](https://github.com/asparagusbeef/cchat/actions/workflows/test.yml)
4
+ [![codecov](https://codecov.io/gh/asparagusbeef/cchat/graph/badge.svg)](https://codecov.io/gh/asparagusbeef/cchat)
5
+ [![PyPI](https://img.shields.io/pypi/v/cchat)](https://pypi.org/project/cchat/)
6
+ [![Python](https://img.shields.io/pypi/pyversions/cchat)](https://pypi.org/project/cchat/)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+
3
9
  Browse and search Claude Code conversation history from the terminal.
4
10
 
5
11
  `cchat` reads the JSONL conversation logs that Claude Code stores in `~/.claude/projects/` and presents them as readable conversation turns. It handles compaction stitching, branch detection, and the full UUID tree structure so you don't have to parse raw JSONL yourself.
@@ -104,6 +110,48 @@ cchat:
104
110
  - No dependencies (stdlib only)
105
111
  - Clipboard copy uses `clip.exe` (WSL) — other platforms not yet supported
106
112
 
113
+ ## Contributing
114
+
115
+ ### Setup
116
+
117
+ ```bash
118
+ git clone https://github.com/asparagusbeef/cchat.git
119
+ cd cchat
120
+ pip install -e ".[test]"
121
+ ```
122
+
123
+ ### Running tests
124
+
125
+ ```bash
126
+ pytest -v # full suite
127
+ pytest tests/test_session.py -v # single module
128
+ pytest --cov=cchat --cov-report=term # with coverage
129
+ ```
130
+
131
+ ### Project structure
132
+
133
+ ```
134
+ cchat.py # entire application (single-file)
135
+ tests/
136
+ conftest.py # shared fixtures, mock project dirs
137
+ fixtures/ # synthetic JSONL sessions matching real Claude Code format
138
+ test_utils.py
139
+ test_formatting.py
140
+ test_session.py
141
+ test_message_extraction.py
142
+ test_project_resolver.py
143
+ test_session_index.py
144
+ test_cli.py
145
+ ```
146
+
147
+ Test fixtures are synthetic JSONL files that match the real Claude Code session format (streaming chunks, UUID trees, compaction boundaries). See `tests/fixtures/` for examples of the expected data shape.
148
+
149
+ ### Guidelines
150
+
151
+ - Keep it as a single Python file with zero runtime dependencies.
152
+ - Tests go in `tests/`. Fixtures go in `tests/fixtures/`.
153
+ - Run the full test suite before opening a PR.
154
+
107
155
  ## License
108
156
 
109
157
  MIT
@@ -3,7 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- __version__ = "0.1.0"
6
+ __version__ = "1.0.0"
7
7
 
8
8
  import argparse
9
9
  import json
@@ -123,6 +123,24 @@ class BranchPoint:
123
123
  line_index: int # file position of the parent entry
124
124
 
125
125
 
126
+ @dataclass
127
+ class BranchChild:
128
+ child_uuid: str
129
+ branch_number: int # 1-indexed by file order
130
+ preview: str # first user text on this branch (~80 chars)
131
+ turn_count: int # user-text turns on this branch only (not common prefix)
132
+ entry_count: int # entries from child to leaf
133
+ is_active: bool
134
+
135
+
136
+ @dataclass
137
+ class BranchInfo:
138
+ parent_uuid: str
139
+ parent_line_index: int
140
+ turn_number: int # which turn on the common prefix this occurs after
141
+ children: list # list[BranchChild]
142
+
143
+
126
144
  # ═══════════════════════════════════════════════════════════════════════════════
127
145
  # UTILITIES
128
146
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -153,6 +171,14 @@ def _truncate(text: str, max_len: int) -> str:
153
171
  return text[:max_len] + "..."
154
172
 
155
173
 
174
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
175
+
176
+
177
+ def _strip_ansi(text: str) -> str:
178
+ """Remove ANSI escape sequences from text."""
179
+ return _ANSI_RE.sub("", text)
180
+
181
+
156
182
  # ═══════════════════════════════════════════════════════════════════════════════
157
183
  # PROJECT RESOLUTION
158
184
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -387,6 +413,7 @@ class Session:
387
413
  self._by_uuid: Optional[dict] = None
388
414
  self._children: Optional[dict] = None
389
415
  self._entry_positions: Optional[dict] = None # uuid -> file line index
416
+ self._logical_parent_map: Optional[dict] = None
390
417
 
391
418
  def _load(self):
392
419
  """Single-pass load of all entries."""
@@ -442,32 +469,35 @@ class Session:
442
469
  self._children[parent].append(uuid)
443
470
  return self._children
444
471
 
445
- def active_path(self, stitch: bool = True) -> list[dict]:
446
- """
447
- Extract the active conversation path.
472
+ @property
473
+ def logical_parent_map(self) -> dict:
474
+ """Map: logicalParentUuid -> compact_boundary UUID. For forward compaction stitching."""
475
+ if self._logical_parent_map is None:
476
+ self._logical_parent_map = {}
477
+ for entry in self.entries:
478
+ lp = entry.get("logicalParentUuid")
479
+ if lp and entry.get("subtype") in ("compact_boundary", "microcompact_boundary"):
480
+ self._logical_parent_map[lp] = entry["uuid"]
481
+ return self._logical_parent_map
448
482
 
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
483
+ def _find_last_uuid(self) -> Optional[dict]:
484
+ """Find the last entry with a UUID that is not a sidechain."""
456
485
  for entry in reversed(self.entries):
457
486
  uuid = entry.get("uuid")
458
487
  if not uuid:
459
488
  continue
460
489
  if entry.get("isSidechain"):
461
490
  continue
462
- last_entry = entry
463
- break
464
-
465
- if not last_entry:
466
- return []
491
+ return entry
492
+ return None
467
493
 
468
- # Walk backward
494
+ def _walk_backward(self, start_uuid: str, stitch: bool = True) -> list[dict]:
495
+ """
496
+ Walk backward from start_uuid via parentUuid, stitching compaction boundaries.
497
+ Returns path in root-to-leaf order.
498
+ """
469
499
  raw_path = []
470
- current_uuid = last_entry.get("uuid")
500
+ current_uuid = start_uuid
471
501
  visited = set()
472
502
 
473
503
  while current_uuid and current_uuid not in visited:
@@ -475,9 +505,6 @@ class Session:
475
505
  entry = self.by_uuid.get(current_uuid)
476
506
  if not entry:
477
507
  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
508
  earliest_line = min(
482
509
  e.get("_line", float("inf")) for e in raw_path
483
510
  )
@@ -497,14 +524,10 @@ class Session:
497
524
 
498
525
  if entry.get("subtype") == "compact_boundary":
499
526
  if stitch:
500
- # Jump to the entry before compaction
501
527
  logical_parent = entry.get("logicalParentUuid")
502
528
  if logical_parent and logical_parent in self.by_uuid:
503
529
  current_uuid = logical_parent
504
530
  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
531
  cb_line = entry.get("_line", float("inf"))
509
532
  fallback = None
510
533
  for e in reversed(self.entries):
@@ -518,7 +541,6 @@ class Session:
518
541
  else:
519
542
  break
520
543
  else:
521
- # No stitching, stop at compaction boundary
522
544
  break
523
545
  else:
524
546
  current_uuid = entry.get("parentUuid")
@@ -526,6 +548,247 @@ class Session:
526
548
  raw_path.reverse()
527
549
  return raw_path
528
550
 
551
+ def _find_leaf(self, start_uuid: str, max_line: float = float("inf")) -> str:
552
+ """
553
+ Forward walk from start_uuid to leaf, with compaction stitching.
554
+ Returns the leaf UUID.
555
+
556
+ 1. Follow children, preferring latest by file position
557
+ 2. At dead end, try logical_parent_map for compaction stitch
558
+ 3. Fallback: find next compact/microcompact boundary in file order
559
+ 4. max_line prevents jumping into another branch's compaction
560
+ """
561
+ current_uuid = start_uuid
562
+ visited = set()
563
+
564
+ while current_uuid not in visited:
565
+ visited.add(current_uuid)
566
+ child_uuids = self.children.get(current_uuid, [])
567
+
568
+ # Filter children within max_line bound
569
+ valid_children = []
570
+ for cu in child_uuids:
571
+ child_entry = self.by_uuid.get(cu)
572
+ if child_entry and child_entry.get("_line", 0) < max_line:
573
+ valid_children.append(cu)
574
+
575
+ if valid_children:
576
+ # Prefer latest by file position
577
+ best = max(valid_children,
578
+ key=lambda u: self.by_uuid[u].get("_line", 0))
579
+ current_uuid = best
580
+ continue
581
+
582
+ # Dead end — try compaction stitching
583
+ stitch_uuid = self.logical_parent_map.get(current_uuid)
584
+ if stitch_uuid and stitch_uuid not in visited:
585
+ stitch_entry = self.by_uuid.get(stitch_uuid)
586
+ if stitch_entry and stitch_entry.get("_line", 0) < max_line:
587
+ current_uuid = stitch_uuid
588
+ continue
589
+
590
+ # Fallback: find next compact/microcompact boundary after current in file order
591
+ current_line = self.by_uuid.get(current_uuid, {}).get("_line", 0)
592
+ best_boundary = None
593
+ best_line = float("inf")
594
+ for entry in self.entries:
595
+ eline = entry.get("_line", 0)
596
+ if eline <= current_line or eline >= max_line:
597
+ continue
598
+ if entry.get("subtype") in ("compact_boundary", "microcompact_boundary"):
599
+ if eline < best_line:
600
+ best_boundary = entry.get("uuid")
601
+ best_line = eline
602
+ break # entries are in file order, first match is earliest
603
+ if best_boundary and best_boundary not in visited:
604
+ current_uuid = best_boundary
605
+ continue
606
+
607
+ break
608
+
609
+ return current_uuid
610
+
611
+ def active_path(self, stitch: bool = True, branch: int = 0) -> list[dict]:
612
+ """
613
+ Extract a conversation path.
614
+
615
+ branch=0: active path (latest leaf, existing behavior)
616
+ branch=N: Nth branch child at first branch point, walked to its leaf
617
+ """
618
+ if branch > 0:
619
+ return self._branch_path(branch, stitch)
620
+
621
+ last_entry = self._find_last_uuid()
622
+ if not last_entry:
623
+ return []
624
+
625
+ return self._walk_backward(last_entry["uuid"], stitch)
626
+
627
+ def _branch_path(self, branch: int, stitch: bool = True) -> list[dict]:
628
+ """Get path for the Nth branch (1-indexed) at the first real branch point."""
629
+ # Get active path to find branch points
630
+ active = self.active_path(stitch=stitch, branch=0)
631
+ if not active:
632
+ return []
633
+
634
+ active_set = {e.get("uuid") for e in active if e.get("uuid")}
635
+
636
+ # Find first real branch point on the active path
637
+ checked_parents = set()
638
+ for entry in active:
639
+ parent_uuid = entry.get("parentUuid")
640
+ if not parent_uuid or parent_uuid in checked_parents:
641
+ continue
642
+ checked_parents.add(parent_uuid)
643
+
644
+ child_uuids = self.children.get(parent_uuid, [])
645
+ if len(child_uuids) <= 1:
646
+ continue
647
+ if self._is_mechanical_fork(parent_uuid, child_uuids):
648
+ continue
649
+
650
+ # Real branch point found — get ALL children sorted by file position
651
+ all_children = sorted(
652
+ child_uuids,
653
+ key=lambda u: self.by_uuid.get(u, {}).get("_line", 0),
654
+ )
655
+
656
+ if branch < 1 or branch > len(all_children):
657
+ print(f"Error: Branch {branch} out of range (1-{len(all_children)})",
658
+ file=sys.stderr)
659
+ sys.exit(1)
660
+
661
+ target_uuid = all_children[branch - 1]
662
+
663
+ # Compute max_line: line of next branch child in file order, or inf
664
+ target_idx = branch - 1
665
+ if target_idx + 1 < len(all_children):
666
+ next_child = all_children[target_idx + 1]
667
+ max_line = self.by_uuid.get(next_child, {}).get("_line", float("inf"))
668
+ else:
669
+ max_line = float("inf")
670
+
671
+ # Walk forward to leaf, then backward
672
+ leaf_uuid = self._find_leaf(target_uuid, max_line)
673
+ path = self._walk_backward(leaf_uuid, stitch)
674
+
675
+ # Include the common prefix (entries up to and including the branch parent)
676
+ parent_entry = self.by_uuid.get(parent_uuid)
677
+ if parent_entry:
678
+ prefix = self._walk_backward(parent_uuid, stitch)
679
+ # Merge: prefix + branch-specific entries
680
+ prefix_uuids = {e.get("uuid") for e in prefix}
681
+ branch_only = [e for e in path if e.get("uuid") not in prefix_uuids]
682
+ return prefix + branch_only
683
+
684
+ return path
685
+
686
+ # No branch points found
687
+ print("Error: No branch points found in this session.", file=sys.stderr)
688
+ sys.exit(1)
689
+
690
+ def get_branch_info(self) -> list[BranchInfo]:
691
+ """
692
+ Get detailed info about all real branch points.
693
+ Returns list[BranchInfo] with preview, turn count, etc. for each child.
694
+ """
695
+ active = self.active_path(stitch=True, branch=0)
696
+ if not active:
697
+ return []
698
+
699
+ active_set = {e.get("uuid") for e in active if e.get("uuid")}
700
+
701
+ # Figure out which turn number each entry falls on
702
+ active_turns = group_into_turns(active, mode="text")
703
+ # Map: uuid -> turn number (1-indexed)
704
+ uuid_to_turn = {}
705
+ for ti, turn in enumerate(active_turns, 1):
706
+ uuid_to_turn[turn.uuid] = ti
707
+
708
+ results = []
709
+ checked_parents = set()
710
+
711
+ for entry in active:
712
+ parent_uuid = entry.get("parentUuid")
713
+ if not parent_uuid or parent_uuid in checked_parents:
714
+ continue
715
+ checked_parents.add(parent_uuid)
716
+
717
+ child_uuids = self.children.get(parent_uuid, [])
718
+ if len(child_uuids) <= 1:
719
+ continue
720
+ if self._is_mechanical_fork(parent_uuid, child_uuids):
721
+ continue
722
+
723
+ # Real branch point — get ALL children sorted by file position
724
+ all_children = sorted(
725
+ child_uuids,
726
+ key=lambda u: self.by_uuid.get(u, {}).get("_line", 0),
727
+ )
728
+
729
+ # Determine which turn this branch occurs after
730
+ parent_entry = self.by_uuid.get(parent_uuid)
731
+ turn_number = uuid_to_turn.get(parent_uuid, 0)
732
+ # If parent itself isn't a turn, find nearest prior turn
733
+ if turn_number == 0 and parent_entry:
734
+ parent_line = parent_entry.get("_line", 0)
735
+ for turn in reversed(active_turns):
736
+ turn_entry = self.by_uuid.get(turn.uuid)
737
+ if turn_entry and turn_entry.get("_line", 0) <= parent_line:
738
+ turn_number = uuid_to_turn.get(turn.uuid, 0)
739
+ break
740
+
741
+ branch_children = []
742
+ for ci, child_uuid in enumerate(all_children, 1):
743
+ # Compute max_line for this child
744
+ if ci < len(all_children):
745
+ next_child = all_children[ci]
746
+ max_line = self.by_uuid.get(next_child, {}).get("_line", float("inf"))
747
+ else:
748
+ max_line = float("inf")
749
+
750
+ # Walk forward to leaf, backward to get full path
751
+ leaf_uuid = self._find_leaf(child_uuid, max_line)
752
+ child_path = self._walk_backward(leaf_uuid, stitch=True)
753
+
754
+ # Filter to entries AFTER the branch parent
755
+ parent_line = parent_entry.get("_line", 0) if parent_entry else 0
756
+ branch_entries = [
757
+ e for e in child_path
758
+ if e.get("_line", 0) > parent_line
759
+ and e.get("_line", 0) < max_line
760
+ ]
761
+
762
+ # Get turns on this branch segment
763
+ branch_turns = group_into_turns(branch_entries, mode="text")
764
+
765
+ # Preview: first user text on this branch
766
+ preview = ""
767
+ if branch_turns:
768
+ preview = branch_turns[0].user_text.replace("\n", " ").strip()
769
+ if len(preview) > 80:
770
+ preview = preview[:80] + "..."
771
+
772
+ is_active = child_uuid in active_set
773
+
774
+ branch_children.append(BranchChild(
775
+ child_uuid=child_uuid,
776
+ branch_number=ci,
777
+ preview=preview,
778
+ turn_count=len(branch_turns),
779
+ entry_count=len(branch_entries),
780
+ is_active=is_active,
781
+ ))
782
+
783
+ results.append(BranchInfo(
784
+ parent_uuid=parent_uuid,
785
+ parent_line_index=parent_entry.get("_line", 0) if parent_entry else 0,
786
+ turn_number=turn_number,
787
+ children=branch_children,
788
+ ))
789
+
790
+ return results
791
+
529
792
  def branch_points(self) -> list[BranchPoint]:
530
793
  """
531
794
  Find true user-initiated branch points (excluding mechanical fan-out).
@@ -664,7 +927,7 @@ def group_into_turns(raw_path: list[dict], mode: str = "text",
664
927
  # Extract text from user messages
665
928
  user_text = None
666
929
  if isinstance(content, str) and content.strip():
667
- user_text = content
930
+ user_text = _strip_ansi(content)
668
931
  elif isinstance(content, list):
669
932
  # List content may have text blocks alongside tool_results
670
933
  text_parts = []
@@ -674,7 +937,7 @@ def group_into_turns(raw_path: list[dict], mode: str = "text",
674
937
  if t:
675
938
  text_parts.append(t)
676
939
  if text_parts:
677
- user_text = "\n".join(text_parts)
940
+ user_text = _strip_ansi("\n".join(text_parts))
678
941
 
679
942
  if user_text:
680
943
  is_compact = bool(entry.get("isCompactSummary"))
@@ -704,7 +967,7 @@ def group_into_turns(raw_path: list[dict], mode: str = "text",
704
967
  btype = block.get("type")
705
968
 
706
969
  if btype == "text":
707
- text = block.get("text", "")
970
+ text = _strip_ansi(block.get("text", ""))
708
971
  if text.strip():
709
972
  if current_turn.assistant_text:
710
973
  current_turn.assistant_text += "\n" + text
@@ -904,7 +1167,7 @@ def format_raw_message(msg: RawMessage, index: int, total: int,
904
1167
  if msg.uuid:
905
1168
  header += f" uuid={msg.uuid[:12]}"
906
1169
 
907
- return f"{header}\n{'─' * 60}\n{msg.content}\n"
1170
+ return f"{header}\n{'─' * 60}\n{_strip_ansi(msg.content)}\n"
908
1171
 
909
1172
 
910
1173
  def format_turns_json(turns: list[Turn], session_id: str, total: int,
@@ -1093,7 +1356,22 @@ def cmd_list(args):
1093
1356
  modified = ts.strftime("%Y-%m-%d %H:%M")
1094
1357
  else:
1095
1358
  modified = s.modified[:16]
1359
+
1360
+ # Compute real turn count + branch info
1096
1361
  msg_info = f"{s.message_count} msgs"
1362
+ try:
1363
+ session = Session(s.path)
1364
+ raw_path = session.active_path(stitch=True)
1365
+ turns = group_into_turns(raw_path, mode="text")
1366
+ turn_count = len(turns)
1367
+ branch_infos = session.get_branch_info()
1368
+ n_branches = len(branch_infos)
1369
+ msg_info = f"{turn_count} turn{'s' if turn_count != 1 else ''}"
1370
+ if n_branches:
1371
+ msg_info += f", {n_branches} branch pt{'s' if n_branches != 1 else ''}"
1372
+ except Exception:
1373
+ pass # fallback to messageCount already set
1374
+
1097
1375
  print(f"[{i}] {s.session_id[:8]}... ({msg_info}, {modified})")
1098
1376
 
1099
1377
  display = s.summary or s.first_prompt
@@ -1112,7 +1390,7 @@ def cmd_view(args):
1112
1390
  session_file = resolve_session(project_dir, args.session)
1113
1391
  session = Session(session_file)
1114
1392
 
1115
- raw_path = session.active_path(stitch=not args.no_stitch)
1393
+ raw_path = session.active_path(stitch=not args.no_stitch, branch=args.branch)
1116
1394
  if not raw_path:
1117
1395
  print("No messages in this session.")
1118
1396
  return
@@ -1180,7 +1458,7 @@ def cmd_copy(args):
1180
1458
  session_file = resolve_session(project_dir, args.session)
1181
1459
  session = Session(session_file)
1182
1460
 
1183
- raw_path = session.active_path(stitch=True)
1461
+ raw_path = session.active_path(stitch=True, branch=args.branch)
1184
1462
  if not raw_path:
1185
1463
  print("No messages in this session.", file=sys.stderr)
1186
1464
  sys.exit(1)
@@ -1349,22 +1627,20 @@ def cmd_tree(args):
1349
1627
  session_file = resolve_session(project_dir, args.session)
1350
1628
  session = Session(session_file)
1351
1629
 
1352
- raw_path = session.active_path(stitch=True)
1630
+ raw_path = session.active_path(stitch=True, branch=args.branch)
1353
1631
  if not raw_path:
1354
1632
  print("No messages in this session.")
1355
1633
  return
1356
1634
 
1357
1635
  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}
1636
+ branch_infos = session.get_branch_info()
1362
1637
 
1638
+ label = "Active path" if args.branch == 0 else f"Branch {args.branch}"
1363
1639
  print(f"Session: {session.session_id}")
1364
- print(f"Turns: {len(turns)}, Branch points: {len(branch_points)}")
1640
+ print(f"{label}: {len(turns)} turns")
1365
1641
  print("=" * 60)
1366
1642
 
1367
- # Show turns with branch markers
1643
+ # Show turns
1368
1644
  for i, turn in enumerate(turns, 1):
1369
1645
  prefix = "├──" if i < len(turns) else "└──"
1370
1646
  user_preview = turn.user_text.replace("\n", " ")[:60]
@@ -1377,21 +1653,23 @@ def cmd_tree(args):
1377
1653
  asst_preview = turn.assistant_text.replace("\n", " ")[:60]
1378
1654
  if len(turn.assistant_text) > 60:
1379
1655
  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 ''}")
1656
+ indent = "│ " if i < len(turns) else " "
1657
+ print(f"{indent}Assistant: {asst_preview}")
1658
+
1659
+ # Show branch details
1660
+ if branch_infos:
1661
+ for bi in branch_infos:
1662
+ n_children = len(bi.children)
1663
+ turn_label = f"turn #{bi.turn_number}" if bi.turn_number else "start"
1664
+ print(f"\nBranches at {turn_label} ({n_children} branches):")
1665
+ print("─" * 40)
1666
+ for bc in bi.children:
1667
+ active_marker = " <- active" if bc.is_active else ""
1668
+ preview = f'"{bc.preview}"' if bc.preview else "(empty)"
1669
+ print(f' [{bc.branch_number}] {preview} '
1670
+ f'({bc.turn_count} turn{"s" if bc.turn_count != 1 else ""})'
1671
+ f'{active_marker}')
1672
+ print(f"\nUse --branch N to view a specific branch")
1395
1673
 
1396
1674
 
1397
1675
  def cmd_export(args):
@@ -1400,7 +1678,7 @@ def cmd_export(args):
1400
1678
  session_file = resolve_session(project_dir, args.session)
1401
1679
  session = Session(session_file)
1402
1680
 
1403
- raw_path = session.active_path(stitch=True)
1681
+ raw_path = session.active_path(stitch=True, branch=args.branch)
1404
1682
  if not raw_path:
1405
1683
  print("No messages in this session.")
1406
1684
  return
@@ -1485,6 +1763,8 @@ def build_parser() -> argparse.ArgumentParser:
1485
1763
  help="Include compaction summary messages")
1486
1764
  view_p.add_argument("--truncate", type=int, default=500, metavar="LEN",
1487
1765
  help="Truncate length for raw content (default: 500, -1=none)")
1766
+ view_p.add_argument("--branch", "-b", type=int, default=0, metavar="N",
1767
+ help="View Nth branch (1-indexed) instead of active path")
1488
1768
 
1489
1769
  # copy
1490
1770
  copy_p = subparsers.add_parser("copy", aliases=["cp"],
@@ -1500,6 +1780,8 @@ def build_parser() -> argparse.ArgumentParser:
1500
1780
  help="Include tool summaries")
1501
1781
  copy_p.add_argument("--raw", action="store_true",
1502
1782
  help="Copy raw messages")
1783
+ copy_p.add_argument("--branch", "-b", type=int, default=0, metavar="N",
1784
+ help="Copy from Nth branch instead of active path")
1503
1785
 
1504
1786
  # projects (no --project flag needed)
1505
1787
  subparsers.add_parser("projects", help="List all projects")
@@ -1517,6 +1799,8 @@ def build_parser() -> argparse.ArgumentParser:
1517
1799
  _add_project_arg(tree_p)
1518
1800
  tree_p.add_argument("session", nargs="?",
1519
1801
  help="Session index or UUID prefix")
1802
+ tree_p.add_argument("--branch", "-b", type=int, default=0, metavar="N",
1803
+ help="Show tree for Nth branch instead of active path")
1520
1804
 
1521
1805
  # export
1522
1806
  export_p = subparsers.add_parser("export", help="Export full session")
@@ -1529,6 +1813,8 @@ def build_parser() -> argparse.ArgumentParser:
1529
1813
  help="Export raw messages")
1530
1814
  export_p.add_argument("--include-tools", action="store_true",
1531
1815
  help="Include tool calls in export")
1816
+ export_p.add_argument("--branch", "-b", type=int, default=0, metavar="N",
1817
+ help="Export Nth branch instead of active path")
1532
1818
 
1533
1819
  return parser
1534
1820
 
@@ -22,5 +22,11 @@ classifiers = [
22
22
  Homepage = "https://github.com/asparagusbeef/cchat"
23
23
  Issues = "https://github.com/asparagusbeef/cchat/issues"
24
24
 
25
+ [project.optional-dependencies]
26
+ test = ["pytest>=7.0", "pytest-cov>=4.0"]
27
+
25
28
  [project.scripts]
26
29
  cchat = "cchat:main"
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
File without changes