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:
|
|
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
|
+
[](https://github.com/asparagusbeef/cchat/actions/workflows/test.yml)
|
|
24
|
+
[](https://codecov.io/gh/asparagusbeef/cchat)
|
|
25
|
+
[](https://pypi.org/project/cchat/)
|
|
26
|
+
[](https://pypi.org/project/cchat/)
|
|
27
|
+
[](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
|
+
[](https://github.com/asparagusbeef/cchat/actions/workflows/test.yml)
|
|
4
|
+
[](https://codecov.io/gh/asparagusbeef/cchat)
|
|
5
|
+
[](https://pypi.org/project/cchat/)
|
|
6
|
+
[](https://pypi.org/project/cchat/)
|
|
7
|
+
[](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__ = "
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if not last_entry:
|
|
466
|
-
return []
|
|
491
|
+
return entry
|
|
492
|
+
return None
|
|
467
493
|
|
|
468
|
-
|
|
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 =
|
|
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
|
-
|
|
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"
|
|
1640
|
+
print(f"{label}: {len(turns)} turns")
|
|
1365
1641
|
print("=" * 60)
|
|
1366
1642
|
|
|
1367
|
-
# Show turns
|
|
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 = "│
|
|
1381
|
-
print(f"{indent}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|