Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ccview

A terminal-based explorer and renderer for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [OpenCode](https://github.com/opencode-ai/opencode) conversation histories. Browse projects, view conversations with markdown rendering, inspect sub-agents, search across sessions, and export to HTML/Markdown/JSONL.
A terminal-based explorer and renderer for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://github.com/opencode-ai/opencode), and Codex CLI conversation histories. Browse projects, view conversations with markdown rendering, inspect sub-agents, search across sessions, and export to HTML/Markdown/JSONL.

Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Lip Gloss](https://github.com/charmbracelet/lipgloss), and [Glamour](https://github.com/charmbracelet/glamour).

Expand Down Expand Up @@ -85,7 +85,7 @@ ccview

Split-pane interactive explorer. Left pane shows the project tree with conversations, plans, and memory files. Right pane renders conversation content with syntax-highlighted markdown.

- **Multi-provider support** - Claude Code (`~/.claude/`) and OpenCode (`~/.local/share/opencode/opencode.db`) side by side. Tabbed UI when both are available; tabs hidden with only one provider.
- **Multi-provider support** - Claude Code (`~/.claude/`), OpenCode (`~/.local/share/opencode/opencode.db`), and Codex (`~/.codex/`) side by side. Tabbed UI when multiple providers are available; tabs hidden with only one provider.
- Conversations sorted newest-first with smart timestamps
- Sub-agents collapsed by default, expand on selection
- Global plans shown in sidebar
Expand Down Expand Up @@ -141,7 +141,7 @@ For full conversations with sub-agents, HTML export creates a directory with `in

| Key | Action |
|-----|--------|
| `1` / `2` | Switch provider tab |
| `1`-`9` | Switch provider tab |
| `/` | Open session search (global) |

### Viewer
Expand Down Expand Up @@ -193,6 +193,21 @@ ccview reads from multiple sources:
| Sessions | Grouped by project (worktree path) |
| Message parts | Text, tool calls, reasoning, patches, and file references |

#### Codex CLI (`~/.codex/`)

| Source | Description |
|--------|-------------|
| `~/.codex/sessions/YYYY/MM/DD/*.jsonl` | JSONL rollout session logs |
| `~/.codex/state_*.sqlite` | Thread index with cwd, title, git metadata, model, source, token totals, and subagent edges |
| `session_meta` records | Session ID, cwd, originator, CLI version, model provider |
| `response_item` records | User/assistant messages, reasoning summaries, function calls, tool outputs |
| `turn_context` records | Runtime context such as model, sandbox, approval policy, timezone |
| `event_msg` records | Token usage, task lifecycle, patch/file-edit, web search, and image-generation metadata |
| `~/.codex/session_index.jsonl` | Session titles and updated timestamps |
| `~/.codex/shell_snapshots/*.sh` | Shell environment snapshots linked back to sessions when available |
| `~/.codex/goals_*.sqlite` | Thread goals and token budgets (available for future UI surfacing) |
| `~/.codex/history.jsonl` | Prompt history by session (available for future fallback titles/search) |

### Message types rendered

- **User messages** - with markdown rendering
Expand All @@ -208,6 +223,7 @@ main.go CLI entry point, flag parsing
provider.go Provider interface (abstracts data sources)
provider_claude.go Claude Code provider (filesystem/JSONL)
provider_opencode.go OpenCode provider (SQLite)
provider_codex.go Codex provider (filesystem/JSONL rollouts)
data.go Tree types, project scanning, filesystem loading
parse.go JSONL parsing, glamour rendering, formatting helpers
ui.go Bubble Tea TUI with split-pane layout, search
Expand Down
42 changes: 24 additions & 18 deletions data.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ type TreeData struct {
}

type TreeStats struct {
TotalProjects int `json:"totalProjects"`
TotalConversations int `json:"totalConversations"`
TotalMessages int `json:"totalMessages"`
TotalProjects int `json:"totalProjects"`
TotalConversations int `json:"totalConversations"`
TotalMessages int `json:"totalMessages"`
TotalTokens int64 `json:"totalTokens,omitempty"`
}

type TreeFileRef struct {
Expand All @@ -41,24 +42,29 @@ type TreeProject struct {
LastActive string `json:"lastActive"`
ConvCount int `json:"convCount"`
MsgCount int `json:"msgCount"`
Source string `json:"source,omitempty"` // "claude" or "opencode"
TokenCount int64 `json:"tokenCount,omitempty"`
Source string `json:"source,omitempty"` // "claude", "opencode", or "codex"
}

type TreeConversation struct {
SessionID string `json:"sessionId"`
Path string `json:"path"`
ModTime string `json:"modTime"`
Title string `json:"title"`
Preview string `json:"preview"`
MsgCount int `json:"msgCount"`
CWD string `json:"cwd,omitempty"`
Version string `json:"version,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
Slug string `json:"slug,omitempty"`
SubAgents []TreeSubAgent `json:"subAgents,omitempty"`
FileEditCount int `json:"fileEditCount,omitempty"`
ToolResults int `json:"toolResults,omitempty"`
Source string `json:"source,omitempty"` // "claude" or "opencode"
SessionID string `json:"sessionId"`
Path string `json:"path"`
ModTime string `json:"modTime"`
Title string `json:"title"`
Preview string `json:"preview"`
MsgCount int `json:"msgCount"`
TokenCount int64 `json:"tokenCount,omitempty"`
Model string `json:"model,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
CWD string `json:"cwd,omitempty"`
Version string `json:"version,omitempty"`
GitBranch string `json:"gitBranch,omitempty"`
Slug string `json:"slug,omitempty"`
SubAgents []TreeSubAgent `json:"subAgents,omitempty"`
ShellSnapshots []TreeFileRef `json:"shellSnapshots,omitempty"`
FileEditCount int `json:"fileEditCount,omitempty"`
ToolResults int `json:"toolResults,omitempty"`
Source string `json:"source,omitempty"` // "claude", "opencode", or "codex"
}

type TreeSubAgent struct {
Expand Down
12 changes: 6 additions & 6 deletions export.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const exportHTMLHeader = `<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code Conversation</title>
<title>AI Conversation</title>
<style>
* { box-sizing: border-box; }
body {
Expand Down Expand Up @@ -138,7 +138,7 @@ a { color: #58a6ff; }
</head>
<body>
<div class="header">
<h1>Claude Code Conversation</h1>
<h1>AI Conversation</h1>
<div class="meta">Source: %s</div>
</div>
`
Expand Down Expand Up @@ -273,7 +273,7 @@ func exportMarkdown(entries []Entry, outPath, sourcePath string) error {
}
defer f.Close()

fmt.Fprintf(f, "# Claude Code Conversation\n\nSource: `%s`\n\n---\n\n", sourcePath)
fmt.Fprintf(f, "# AI Conversation\n\nSource: `%s`\n\n---\n\n", sourcePath)

for _, entry := range entries {
switch entry.Type {
Expand Down Expand Up @@ -367,7 +367,7 @@ func exportHTMLDir(convPath string, proj *TreeProject, outDir, indexName string)

if conv == nil || len(conv.SubAgents) == 0 {
// No subagents, just export single file
entries, err := parseConversation(convPath)
entries, err := parseAnyConversation(convPath)
if err != nil {
return err
}
Expand Down Expand Up @@ -401,7 +401,7 @@ func exportHTMLDir(convPath string, proj *TreeProject, outDir, indexName string)
navHTML := navBuf.String()

// Export main conversation as index.html with nav
entries, err := parseConversation(convPath)
entries, err := parseAnyConversation(convPath)
if err != nil {
return err
}
Expand All @@ -420,7 +420,7 @@ func exportHTMLDir(convPath string, proj *TreeProject, outDir, indexName string)
at = "agent"
}
saFile := fmt.Sprintf("subagent-%s-%s.html", at, sa.Name[:min(8, len(sa.Name))])
saEntries, err := parseConversation(sa.Path)
saEntries, err := parseAnyConversation(sa.Path)
if err != nil {
continue
}
Expand Down
14 changes: 3 additions & 11 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func main() {
fmt.Fprintln(os.Stderr, "Usage: ccview --export output.html --file conversation.jsonl")
os.Exit(1)
}
entries, err := parseConversation(src)
entries, err := parseAnyConversation(src)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", src, err)
os.Exit(1)
Expand All @@ -50,17 +50,9 @@ func main() {
}

// Discover available providers
var providers []Provider
claude := &ClaudeProvider{}
if claude.Available() {
providers = append(providers, claude)
}
opencode := NewOpenCodeProvider()
if opencode.Available() {
providers = append(providers, opencode)
}
providers := discoverProviders()
if len(providers) == 0 && *filePath == "" {
fmt.Fprintln(os.Stderr, "No session data found. Checked ~/.claude/ and ~/.local/share/opencode/")
fmt.Fprintln(os.Stderr, "No session data found. Checked ~/.claude/, ~/.local/share/opencode/, and ~/.codex/")
os.Exit(1)
}

Expand Down
7 changes: 7 additions & 0 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ func parseConversation(path string) ([]Entry, error) {
return entries, scanner.Err()
}

func parseAnyConversation(path string) ([]Entry, error) {
if isCodexSessionFile(path) {
return NewCodexProvider().parseSession(path)
}
return parseConversation(path)
}

// ── Metadata scanning (fast single-pass) ──

type conversationMeta struct {
Expand Down
75 changes: 75 additions & 0 deletions provider.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package main

import (
"fmt"
"sort"
"strings"
)

// Provider abstracts a session data source (Claude Code, OpenCode, etc.).
type Provider interface {
Name() string
Expand All @@ -19,3 +25,72 @@ type SearchResult struct {
ModTime string
ProjIndex int // index into provider's tree.Projects
}

func discoverProviders() []Provider {
var providers []Provider
claude := &ClaudeProvider{}
if claude.Available() {
providers = append(providers, claude)
}
opencode := NewOpenCodeProvider()
if opencode.Available() {
providers = append(providers, opencode)
}
codex := NewCodexProvider()
if codex.Available() {
providers = append(providers, codex)
}
return providers
}

func mergeProviderTrees(providers []Provider) (*TreeData, error) {
merged := &TreeData{}
for _, provider := range providers {
tree, err := provider.LoadTree()
if err != nil {
return nil, fmt.Errorf("%s: %w", provider.Name(), err)
}
if tree == nil {
continue
}
if merged.ClaudeDir == "" {
merged.ClaudeDir = tree.ClaudeDir
}
if merged.GlobalClaudeMD == "" {
merged.GlobalClaudeMD = tree.GlobalClaudeMD
}
merged.Plans = append(merged.Plans, tree.Plans...)
merged.Projects = append(merged.Projects, tree.Projects...)
merged.Stats.TotalProjects += tree.Stats.TotalProjects
merged.Stats.TotalConversations += tree.Stats.TotalConversations
merged.Stats.TotalMessages += tree.Stats.TotalMessages
merged.Stats.TotalTokens += tree.Stats.TotalTokens
}
sort.Slice(merged.Projects, func(i, j int) bool {
return merged.Projects[i].LastActive > merged.Projects[j].LastActive
})
return merged, nil
}

func providerForSource(providers []Provider, source string) Provider {
source = strings.ToLower(source)
for _, provider := range providers {
if providerSourceKey(provider) == source {
return provider
}
}
return nil
}

func providerSourceKey(provider Provider) string {
switch provider.(type) {
case *ClaudeProvider:
return "claude"
case *OpenCodeProvider:
return "opencode"
case *CodexProvider:
return "codex"
default:
return strings.ToLower(strings.ReplaceAll(provider.Name(), " ", ""))
}
}
Loading