Agent-focused documentation. See README.md for user-facing docs.
just build # Build release binary
just test # Run tests
just install # Build and install to ~/.local/bin (macOS signing if applicable)
just lint # Run clippysrc/
main.rs # CLI orchestration, display, skim integration
session.rs # Session domain model (Session, SessionSource)
claude_code.rs # Claude Code JSONL loading/parsing
message_classification.rs # Shared user-message classification rules
interactive_state.rs # Pure reducer for interactive state transitions
remote.rs # Remote sync config + SSH/rsync operations
Boundary principle: If Claude Code changes its storage format, changes should be isolated to claude_code.rs. Session domain types live in session.rs; interactive navigation/search transitions live in interactive_state.rs; shared message filtering rules live in message_classification.rs.
| Module | Responsibility | Changes when... |
|---|---|---|
main.rs |
CLI args, high-level orchestration, output formatting | UI/UX flow or command behavior changes |
session.rs |
Session model and source typing | Session field/source semantics change |
claude_code.rs |
JSONL reading, metadata extraction, scan/search text generation | Claude Code format changes |
message_classification.rs |
Shared classification for first prompt + turn counting | User-content filtering rules change |
interactive_state.rs |
Interactive reducer (Esc, Ctrl+S, arrows, Enter) |
Navigation/search state machine changes |
remote.rs |
Remote config loading and sync summaries | SSH/rsync behavior or remote policy changes |
~/.claude/projects/
-Users-you-project-a/
abc12345-1234-1234-1234-123456789abc.jsonl # Session transcript (UUID filename)
def45678-5678-5678-5678-567890123def.jsonl
-Users-you-project-b/
ghi78901-9012-9012-9012-901234567890.jsonl
Sessions are identified by UUID filenames. Non-UUID files (like agent-*) are filtered out.
flowchart LR
subgraph "Session .jsonl file"
direction TB
U1[user] --> A1[assistant]
A1 --> U2[user]
U2 --> A2[assistant]
end
subgraph "Message Content"
direction TB
UM["user.message.content<br/>─────────────────<br/>string | ContentBlock[]"]
AM["assistant.message.content[]<br/>─────────────────<br/>• type: text<br/>• type: thinking<br/>• type: tool_use"]
end
subgraph "Other Entry Types"
SUM[summary]
CT[custom-title]
SYS[system]
end
U1 -.-> UM
A1 -.-> AM
All metadata is extracted directly from .jsonl files (no index dependency):
- Walk
~/.claude/projects/*/for.jsonlfiles - Validate filename is a UUID (8-4-4-4-12 hex format)
- Single-pass scan collects:
cwd, firstusermessage,forkedFrom, turn count, lastsummary/custom-title/tagentries, skip flags - Timestamps from filesystem (created, modified)
- Filter out sidechain/teammate sessions and empty sessions (no cwd, no user message, no summary)
Uses rayon for parallel processing across files.
Session filenames must match UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
This filters out:
agent-*files (subagent sessions spawned by Task tool)- Other non-session files
All fields come from a single full-file pass with a reused line buffer. The first
HEADER_SCAN_LINES (16) lines are parsed fully to capture session-level metadata
(cwd, forkedFrom, isSidechain, teamName — stamped on every entry, so reliably
present early). After that, a SIMD-accelerated byte scan (memchr::memmem) skips
lines that don't mention a content-bearing "type": (user/assistant/summary/
custom-title/tag), avoiding JSON parsing for the ~99% of lines that are
progress/attachment chatter in long-running sessions.
| Field | Source | Selection |
|---|---|---|
project_path |
cwd field |
First occurrence |
first_message |
First user entry passing filters |
First occurrence |
forked_from |
forkedFrom.sessionId field |
First occurrence |
summary |
summary type entry |
Last well-formed occurrence |
name (customTitle) |
custom-title type entry |
Last well-formed occurrence |
tag |
tag type entry |
Last occurrence; empty string clears |
skip |
isSidechain:true or teamName present |
Early return on match |
created / modified |
Filesystem | metadata.created() / .modified() |
Summary and custom-title entries can appear anywhere (compaction mid-session, /rename at any point), so last-wins is the correct semantic.
Entry-level skips (processed for cwd/forkedFrom but excluded from first-prompt, turn count, and search text):
isMeta:true— synthetic messages (attachment context, proactive ticks)isCompactSummary:true— post-compaction summary text
- Set via
/renamecommand in Claude Code - Stored as
{"type":"custom-title","customTitle":"...","sessionId":"..."} - Displayed with
★prefix:★ name - summary - Indicates user-marked important sessions
Sessions can be forked via /fork command or claude --fork-session. A fork creates a new session file that copies the conversation history up to a point, allowing the user to explore an alternate path.
Parent Session (511623e7-...)
├── Fork A (c33eb693-...) # forkedFrom: 511623e7-...
└── Fork B (cc0f0043-...) # forkedFrom: 511623e7-...
Each entry in a forked session contains a forkedFrom field:
{
"type": "user",
"message": {...},
"sessionId": "cc0f0043-...",
"forkedFrom": {
"sessionId": "511623e7-...", // Parent session ID
"messageUuid": "23ede6bc-..." // Corresponding message in parent
}
}| Aspect | Behavior |
|---|---|
| Storage | Fork is a separate .jsonl file with full conversation copy |
| Relationship | forkedFrom.sessionId on entries links to parent |
| Index | sessions-index.json does NOT store fork relationships |
| customTitle | Claude Code auto-appends "(Fork)" to forked session titles |
| Detection | Must extract forkedFrom from JSONL file head (first ~50 lines) |
- Read first ~50 lines of each session file
- Look for
forkedFrom.sessionIdfield on any entry - Build parent→children map after loading all sessions
- A session without
forkedFromis a "root" session - Multiple forks can share the same parent (siblings)
Uses embedded skim crate (no external fzf dependency):
- Build
SkimOptionswith preview command pointing to self - Send
SessionItems through crossbeam channel - Preview generates content directly (no subprocess)
- On selection, spawns
zsh -c "cd <project> && claude -r <session-id>"
Interactive mode uses a navigation stack for exploring fork trees:
- Root view: Shows only root sessions (those without a parent in the session set)
▶indicator: Session has child forks▷indicator: Focused parent (distinguishes from children with forks)- → (right arrow): Drill into selected session, showing parent + direct children only
- ← (left arrow): Go back to previous view (pop from stack)
- Esc: Return to root view (clear entire stack)
Root View After → on e5e2d After → on 398eb
───────────────────── ───────────────────── ─────────────────────
▶ e5e2d (parent) ▷ e5e2d (focused) ▷ 398eb (focused)
f1064 (no forks) ▶ 398eb (child w/forks) 5b88a (direct child)
... c0ff2 (child) c0ff2 (direct child)
Note: Each drill-down shows only direct children, not all descendants. To see grandchildren, drill into the child session.
Ctrl+S performs literal full-text search across session transcripts and replaces the view with matching results. Search respects active filters (-r, -p) — it only searches sessions already loaded in the picker, not the entire filesystem.
- Search: Type query in filter prompt, press Ctrl+S to search
- Results: List shows only sessions containing the search pattern
- Preview: Matching lines highlighted in preview pane
- Esc: Clears search, returns to previous view (root or subtree)
What gets searched: Only user/assistant message content. Tool outputs, system messages, and JSON metadata are excluded. This ensures search results match what the preview shows.
Design choice: Search replaces the view temporarily rather than filtering within the current subtree. This ensures you can find any session regardless of navigation state. The search results persist until explicitly cleared with Esc.
Performance note: The lowercase transcript index is built on a background thread after the picker renders (not during discovery), so startup stays fast and list mode never pays the memory cost. First Ctrl+S joins the thread — typically already finished by the time the user has typed a query.
Normal View After Ctrl+S "api"
───────────────────── ─────────────────────
▶ session-a search: "api" (3 matches) │ esc to clear
session-b session-b (has "api")
▶ session-c session-d (has "api")
session-d session-f (has "api")
Navigation while searching:
- Subtree navigation (← →) is disabled during search mode
- Enter still selects the session to resume
- Esc clears search before clearing subtree focus
Interactive mode displays a header with column legend:
CRE MOD MSG SOURCE PROJECT SUMMARY
| Column | Description |
|---|---|
| CRE | Created timestamp (relative) |
| MOD | Modified timestamp (relative) |
| MSG | Turn count (user messages, excludes system content) |
| SOURCE | Session source (local, remote) |
| PROJECT | Project directory name |
| SUMMARY | ★ name > #tag > summary > first message |
The MSG column shows actual user turns, filtering out system-generated content:
- Entries with
isMeta:trueorisCompactSummary:true - Known system tag prefixes (
<command-...>,<local-command-...>,<bash-...>,<ide_...>,<tick>, etc.) — but NOT arbitrary<text>like<Button> [...]bracketed content/...slash commands
--debug flag adds a 5-character session ID prefix column for debugging navigation.
- Build: Rust 1.88+ (edition 2024)
- Runtime: None (skim is embedded, preview is self-contained)
| Crate | Purpose |
|---|---|
skim |
Embedded fuzzy finder (replaces fzf) |
rayon |
Parallel file processing |
serde_json |
JSONL parsing |
memchr |
SIMD substring search for the line prefilter |
clap |
CLI argument parsing |