Skip to content
Merged
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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

`ccsessions` (from **C**laude **C**ode **Sessions**) is a terminal UI for browsing Claude Code session history for the current working directory.

It maps the current directory to Claude's project history folder under `~/.claude/projects`, loads each `.jsonl` session file, and shows:
It maps the current directory to Claude's project history folder under `~/.claude/projects` by default, or a custom Claude directory passed with `--claude-dir`, loads each `.jsonl` session file, and shows:

- a searchable session list
- session metadata such as timestamps and branch
Expand All @@ -14,6 +14,18 @@ It maps the current directory to Claude's project history folder under `~/.claud
go run ./cmd/ccsessions
```

To read sessions from a different Claude config directory:

```bash
go run ./cmd/ccsessions --claude-dir ~/.claude-personal
```

To show discovery diagnostics in the UI header:

```bash
go run ./cmd/ccsessions --claude-dir ~/.claude-personal --debug
```

## Build

Build the binary into `./bin/ccsessions`:
Expand Down Expand Up @@ -42,6 +54,18 @@ After that, run it with:
ccsessions
```

Or:

```bash
ccsessions --claude-dir ~/.claude-personal
```

You can combine it with:

```bash
ccsessions --debug
```

## Controls

- Type to filter sessions
Expand Down
7 changes: 6 additions & 1 deletion cmd/ccsessions/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"flag"
"fmt"
"os"

Expand All @@ -10,7 +11,11 @@ import (
)

func main() {
model, err := ui.NewModel()
claudeDir := flag.String("claude-dir", "", "Claude config directory to read session history from (defaults to ~/.claude)")
debug := flag.Bool("debug", false, "Show Claude session discovery diagnostics")
flag.Parse()

model, err := ui.NewModel(*claudeDir, *debug)
if err != nil {
fmt.Fprintf(os.Stderr, "startup error: %v\n", err)
os.Exit(1)
Expand Down
72 changes: 59 additions & 13 deletions internal/claude/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ type Entry struct {
IsError bool
}

type DiscoveryInfo struct {
ClaudeDir string
ProjectDir string
ProjectFound bool
SessionCount int
}

type rawRecord struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Expand Down Expand Up @@ -122,27 +129,47 @@ type rawProgress struct {
Message json.RawMessage `json:"message"`
}

func DiscoverForCurrentDir() ([]Session, error) {
func DiscoverForCurrentDir(claudeDir string) ([]Session, DiscoveryInfo, error) {
cwd, err := os.Getwd()
if err != nil {
return nil, err
return nil, DiscoveryInfo{}, err
}

return discoverForDir(cwd, claudeDir)
}

func discoverForDir(cwd, claudeDir string) ([]Session, DiscoveryInfo, error) {
baseDir, err := resolveClaudeDir(claudeDir)
if err != nil {
return nil, DiscoveryInfo{}, err
}

root, err := projectHistoryDir(cwd)
root, err := projectHistoryDir(cwd, baseDir)
if err != nil {
return nil, err
return nil, DiscoveryInfo{}, err
}

info := DiscoveryInfo{
ClaudeDir: baseDir,
ProjectDir: root,
}
if stat, err := os.Stat(root); err == nil && stat.IsDir() {
info.ProjectFound = true
} else if err != nil && !os.IsNotExist(err) {
return nil, info, err
}

matches, err := filepath.Glob(filepath.Join(root, "*.jsonl"))
if err != nil {
return nil, fmt.Errorf("glob session files: %w", err)
return nil, info, fmt.Errorf("glob session files: %w", err)
}
info.SessionCount = len(matches)

sessions := make([]Session, 0, len(matches))
for _, match := range matches {
session, err := ParseSessionFile(match)
if err != nil {
return nil, err
return nil, info, err
}
sessions = append(sessions, session)
}
Expand All @@ -151,17 +178,36 @@ func DiscoverForCurrentDir() ([]Session, error) {
return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt)
})

return sessions, nil
return sessions, info, nil
}

func projectHistoryDir(cwd string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
func projectHistoryDir(cwd, baseDir string) (string, error) {
sanitized := strings.ReplaceAll(filepath.Clean(cwd), string(filepath.Separator), "-")
return filepath.Join(baseDir, "projects", sanitized), nil
}

func resolveClaudeDir(dir string) (string, error) {
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".claude"), nil
}

sanitized := strings.ReplaceAll(filepath.Clean(cwd), string(filepath.Separator), "-")
return filepath.Join(home, ".claude", "projects", sanitized), nil
if dir == "~" {
return os.UserHomeDir()
}

if strings.HasPrefix(dir, "~"+string(filepath.Separator)) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, dir[2:]), nil
}

return filepath.Clean(dir), nil
}

func ParseSessionFile(path string) (session Session, err error) {
Expand Down
117 changes: 117 additions & 0 deletions internal/claude/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package claude

import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
Expand All @@ -15,6 +16,122 @@ func testdataPath(name string) string {
return filepath.Join(filepath.Dir(file), "testdata", name)
}

// --- projectHistoryDir ---

func TestProjectHistoryDir_DefaultClaudeDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

baseDir, err := resolveClaudeDir("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

got, err := projectHistoryDir("/tmp/my-project", baseDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := filepath.Join(home, ".claude", "projects", "-tmp-my-project")
if got != want {
t.Errorf("projectHistoryDir() = %q, want %q", got, want)
}
}

func TestProjectHistoryDir_CustomClaudeDir(t *testing.T) {
baseDir, err := resolveClaudeDir("/tmp/.claude-personal")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

got, err := projectHistoryDir("/tmp/my-project", baseDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := filepath.Join("/tmp/.claude-personal", "projects", "-tmp-my-project")
if got != want {
t.Errorf("projectHistoryDir() = %q, want %q", got, want)
}
}

func TestProjectHistoryDir_TildeClaudeDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

baseDir, err := resolveClaudeDir("~/.claude-personal")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

got, err := projectHistoryDir("/tmp/my-project", baseDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

want := filepath.Join(home, ".claude-personal", "projects", "-tmp-my-project")
if got != want {
t.Errorf("projectHistoryDir() = %q, want %q", got, want)
}
}

func TestDiscoverForDir_DebugInfoFound(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

cwd := "/tmp/my-project"
projectDir, err := projectHistoryDir(cwd, filepath.Join(home, ".claude"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
sessionFile := filepath.Join(projectDir, "session.jsonl")
if err := os.WriteFile(sessionFile, []byte("{\"sessionId\":\"abc123\"}\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}

sessions, info, err := discoverForDir(cwd, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !info.ProjectFound {
t.Fatalf("ProjectFound = false, want true")
}
if info.SessionCount != 1 {
t.Fatalf("SessionCount = %d, want 1", info.SessionCount)
}
if info.ClaudeDir != filepath.Join(home, ".claude") {
t.Fatalf("ClaudeDir = %q, want %q", info.ClaudeDir, filepath.Join(home, ".claude"))
}
if info.ProjectDir != projectDir {
t.Fatalf("ProjectDir = %q, want %q", info.ProjectDir, projectDir)
}
if len(sessions) != 1 {
t.Fatalf("len(sessions) = %d, want 1", len(sessions))
}
}

func TestDiscoverForDir_DebugInfoMissingProject(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

sessions, info, err := discoverForDir("/tmp/missing-project", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.ProjectFound {
t.Fatalf("ProjectFound = true, want false")
}
if info.SessionCount != 0 {
t.Fatalf("SessionCount = %d, want 0", info.SessionCount)
}
if len(sessions) != 0 {
t.Fatalf("len(sessions) = %d, want 0", len(sessions))
}
}

// --- ParseSessionFile ---

func TestParseSessionFile_Simple(t *testing.T) {
Expand Down
34 changes: 28 additions & 6 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,24 +81,28 @@ type Model struct {
err error
focus focusTarget
projectFolder string
debug bool
discovery claude.DiscoveryInfo
}

func NewModel() (Model, error) {
func NewModel(claudeDir string, debug bool) (Model, error) {
search := textinput.New()
search.Placeholder = "Search session history"
search.Prompt = "Search: "
search.Focus()

sessions, err := claude.DiscoverForCurrentDir()
sessions, discovery, err := claude.DiscoverForCurrentDir(claudeDir)
if err != nil {
return Model{}, err
}

model := Model{
search: search,
sessions: sessions,
filtered: sessions,
focus: focusSearch,
search: search,
sessions: sessions,
filtered: sessions,
focus: focusSearch,
debug: debug,
discovery: discovery,
}
model.projectFolder = currentProjectDir(sessions)
model.list = viewport.New(0, 0)
Expand Down Expand Up @@ -172,6 +176,9 @@ func (m Model) View() string {
if m.projectFolder != "" {
header = append(header, mutedStyle.Render(m.projectFolder))
}
if m.debug {
header = append(header, mutedStyle.Render(m.debugSummary()))
}

leftWidth, rightWidth, panelHeight := m.panelDimensions()
list := m.panelStyle(focusList).Width(leftWidth).Height(panelHeight).Render(m.list.View())
Expand All @@ -187,6 +194,21 @@ func (m Model) View() string {
}, "\n\n"))
}

func (m Model) debugSummary() string {
projectState := "missing"
if m.discovery.ProjectFound {
projectState = "found"
}

return fmt.Sprintf(
"debug claude_dir=%s project_dir=%s project=%s sessions=%d",
m.discovery.ClaudeDir,
m.discovery.ProjectDir,
projectState,
m.discovery.SessionCount,
)
}

func (m *Model) applyFilter() {
query := strings.ToLower(strings.TrimSpace(m.search.Value()))
if query == "" {
Expand Down
Loading