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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ Session viewer for Claude Code. Browse, inspect, search, and export conversation
```bash
ccx web # Start web UI at localhost:8080
ccx projects # List all projects
ccx sessions # List sessions (interactive picker)
ccx view # View session (interactive picker)
ccx sessions # List sessions for this workspace
ccx sessions --all # List sessions across all projects
ccx view # View workspace session (interactive picker)
ccx export --format=html # Export to HTML
```

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ cd ccx && make build
```bash
ccx web # Start web UI (recommended)
ccx projects # List all projects
ccx sessions # List recent sessions
ccx sessions --provider=cx # Codex sessions only
ccx sessions # List recent sessions for this workspace
ccx sessions --all # List recent sessions across all projects
ccx sessions --provider=cx # Codex sessions in this workspace
ccx sessions --after=2026-03-01 # Date filtered
ccx view [session] # View in terminal
ccx export -f html --brief # Export conversation-only HTML
Expand Down
216 changes: 216 additions & 0 deletions internal/catalog/session_query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package catalog

import (
"fmt"
"sort"
"strings"

"github.com/thevibeworks/ccx/internal/config"
"github.com/thevibeworks/ccx/internal/parser"
)

type SessionScope string

const (
ScopeAll SessionScope = "all"
ScopeWorkspace SessionScope = "workspace"
ScopeProject SessionScope = "project"
)

type SessionSort string

const (
SortTime SessionSort = "time"
SortMessages SessionSort = "messages"
)

type SessionQuery struct {
Scope SessionScope
WorkspacePath string
ProjectName string
Filter config.SessionFilter
Sort SessionSort
Limit int
}

func (q SessionQuery) WithoutLimit() SessionQuery {
q.Limit = 0
return q
}

func (q SessionQuery) WithoutProviderFilter() SessionQuery {
q.Filter.Provider = ""
return q
}

func ApplySessionQuery(projects []*parser.Project, q SessionQuery) []*parser.Session {
var sessions []*parser.Session
for _, project := range projects {
if project == nil || !projectMatchesScope(project, q) {
continue
}
for _, session := range project.Sessions {
if session == nil {
continue
}
if q.Scope == ScopeWorkspace && !sessionMatchesWorkspace(project, session, q.WorkspacePath) {
continue
}
if session.ProjectName == "" {
session.ProjectName = project.Name
}
if !q.Filter.IsEmpty() && !q.Filter.Match(session) {
continue
}
sessions = append(sessions, session)
}
}
SortSessions(sessions, q.Sort)
if q.Limit > 0 && len(sessions) > q.Limit {
sessions = sessions[:q.Limit]
}
return sessions
}

func SortSessions(sessions []*parser.Session, sortBy SessionSort) {
switch sortBy {
case SortMessages:
sort.SliceStable(sessions, func(i, j int) bool {
return sessions[i].Stats.MessageCount > sessions[j].Stats.MessageCount
})
default:
sort.SliceStable(sessions, func(i, j int) bool {
return sessions[i].EndTime.After(sessions[j].EndTime)
})
}
}

func ValidateSessionSort(sortBy SessionSort) error {
switch sortBy {
case "", SortTime, SortMessages:
return nil
default:
return fmt.Errorf("invalid sort %q (want time or messages)", sortBy)
}
}

func projectMatchesScope(project *parser.Project, q SessionQuery) bool {
switch q.Scope {
case ScopeProject:
return ProjectMatchesName(project, q.ProjectName)
case ScopeWorkspace:
return ProjectMatchesWorkspace(project, q.WorkspacePath)
default:
return true
}
}

func sessionMatchesWorkspace(project *parser.Project, session *parser.Session, workspacePath string) bool {
canonical := parser.CanonicalizeWorkspacePath(workspacePath)
if canonical == "" || session == nil {
return false
}
if strings.TrimSpace(session.CWD) != "" {
return parser.CanonicalizeWorkspacePath(session.CWD) == canonical
}
return ProjectMatchesWorkspace(project, workspacePath)
}

func ProjectMatchesName(project *parser.Project, name string) bool {
if project == nil {
return false
}
query := strings.ToLower(strings.TrimSpace(name))
if query == "" {
return false
}
nameLower := strings.ToLower(project.Name)
encodedLower := strings.ToLower(project.EncodedName)
pathLower := strings.ToLower(project.Path)
if nameLower == query || encodedLower == query || pathLower == query {
return true
}
canonicalQuery := parser.CanonicalizeWorkspacePath(name)
if canonicalQuery != "" {
if parser.CanonicalizeWorkspacePath(project.Path) == canonicalQuery {
return true
}
for _, session := range project.Sessions {
if session != nil && parser.CanonicalizeWorkspacePath(session.CWD) == canonicalQuery {
return true
}
}
}
return strings.Contains(nameLower, query) || strings.Contains(pathLower, query)
}

func ProjectMatchesWorkspace(project *parser.Project, workspacePath string) bool {
if project == nil {
return false
}
canonical := parser.CanonicalizeWorkspacePath(workspacePath)
if canonical == "" {
return false
}
if parser.CanonicalizeWorkspacePath(project.Path) == canonical {
return true
}
if project.EncodedName != "" && parser.CanonicalizeWorkspacePath(parser.DecodePath(project.EncodedName)) == canonical {
return true
}
for _, session := range project.Sessions {
if session == nil {
continue
}
if parser.CanonicalizeWorkspacePath(session.CWD) == canonical {
return true
}
}
return false
}

type AmbiguousSessionError struct {
Query string
Matches []*parser.Session
}

func (e *AmbiguousSessionError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("ambiguous session %q matched %d sessions", e.Query, len(e.Matches))
}

func FindSessionByID(sessions []*parser.Session, query string) (*parser.Session, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}

var exact []*parser.Session
for _, session := range sessions {
if session != nil && session.ID == query {
exact = append(exact, session)
}
}
if len(exact) == 1 {
return exact[0], nil
}
if len(exact) > 1 {
return nil, &AmbiguousSessionError{Query: query, Matches: exact}
}

var prefixed []*parser.Session
for _, session := range sessions {
if session != nil && strings.HasPrefix(session.ID, query) {
prefixed = append(prefixed, session)
}
}
if len(prefixed) == 1 {
return prefixed[0], nil
}
if len(prefixed) > 1 {
return nil, &AmbiguousSessionError{Query: query, Matches: prefixed}
}
return nil, nil
}
140 changes: 140 additions & 0 deletions internal/catalog/session_query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package catalog

import (
"errors"
"testing"
"time"

"github.com/thevibeworks/ccx/internal/config"
"github.com/thevibeworks/ccx/internal/parser"
)

func TestApplySessionQueryScopesToWorkspace(t *testing.T) {
now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
projects := []*parser.Project{
{
Name: "repo",
Path: "/tmp/repo",
Sessions: []*parser.Session{
{ID: "current-cx", Provider: "codex", CWD: "/tmp/repo", EndTime: now},
{ID: "current-cc", Provider: "claude-code", CWD: "/tmp/repo", EndTime: now.Add(-time.Minute)},
},
},
{
Name: "other",
Path: "/tmp/other",
Sessions: []*parser.Session{
{ID: "other", Provider: "codex", CWD: "/tmp/other", EndTime: now.Add(time.Minute)},
},
},
}

got := ApplySessionQuery(projects, SessionQuery{
Scope: ScopeWorkspace,
WorkspacePath: "/tmp/repo",
Sort: SortTime,
})

if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].ID != "current-cx" || got[1].ID != "current-cc" {
t.Fatalf("got IDs %q, %q; want current-cx, current-cc", got[0].ID, got[1].ID)
}
}

func TestApplySessionQueryAllPreservesGlobalCandidateSet(t *testing.T) {
now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
projects := []*parser.Project{
{Name: "repo", Sessions: []*parser.Session{{ID: "repo", EndTime: now}}},
{Name: "other", Sessions: []*parser.Session{{ID: "other", EndTime: now.Add(time.Minute)}}},
}

got := ApplySessionQuery(projects, SessionQuery{Scope: ScopeAll, Sort: SortTime})
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].ID != "other" || got[1].ID != "repo" {
t.Fatalf("global ordering = [%s %s], want [other repo]", got[0].ID, got[1].ID)
}
}

func TestApplySessionQueryProjectScopeIsExplicit(t *testing.T) {
projects := []*parser.Project{
{Name: "repo", EncodedName: "tmp-repo", Path: "/tmp/repo", Sessions: []*parser.Session{{ID: "repo"}}},
{Name: "other", EncodedName: "tmp-other", Path: "/tmp/other", Sessions: []*parser.Session{{ID: "other"}}},
}

got := ApplySessionQuery(projects, SessionQuery{
Scope: ScopeProject,
ProjectName: "tmp-other",
})
if len(got) != 1 || got[0].ID != "other" {
t.Fatalf("got %+v, want only other project session", got)
}
}

func TestApplySessionQueryFiltersSortsAndLimits(t *testing.T) {
base := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
projects := []*parser.Project{{
Name: "repo",
Sessions: []*parser.Session{
{ID: "cc-big", Provider: "claude-code", Summary: "auth refactor", Model: "sonnet", StartTime: base, EndTime: base.Add(time.Minute), Stats: parser.SessionStats{MessageCount: 20}},
{ID: "cx-small", Provider: "codex", Summary: "auth refactor", Model: "gpt-5", StartTime: base, EndTime: base.Add(2 * time.Minute), Stats: parser.SessionStats{MessageCount: 4}},
{ID: "cx-big", Provider: "codex", Summary: "billing", Model: "gpt-5", StartTime: base, EndTime: base.Add(3 * time.Minute), Stats: parser.SessionStats{MessageCount: 40}},
},
}}

got := ApplySessionQuery(projects, SessionQuery{
Scope: ScopeAll,
Filter: config.SessionFilter{
Provider: "codex",
Model: "gpt",
},
Sort: SortMessages,
Limit: 1,
})
if len(got) != 1 || got[0].ID != "cx-big" {
t.Fatalf("got %+v, want cx-big", got)
}
}

func TestFindSessionByIDReportsAmbiguousPrefix(t *testing.T) {
sessions := []*parser.Session{
{ID: "abcdef-one"},
{ID: "abcdef-two"},
}

got, err := FindSessionByID(sessions, "abcdef")
if got != nil {
t.Fatalf("session = %+v, want nil on ambiguity", got)
}
var ambiguous *AmbiguousSessionError
if !errors.As(err, &ambiguous) {
t.Fatalf("err = %v, want AmbiguousSessionError", err)
}
if len(ambiguous.Matches) != 2 {
t.Fatalf("matches = %d, want 2", len(ambiguous.Matches))
}
}

func TestFindSessionByIDPrefersExactBeforePrefixAmbiguity(t *testing.T) {
sessions := []*parser.Session{
{ID: "abc"},
{ID: "abcdef"},
}

got, err := FindSessionByID(sessions, "abc")
if err != nil {
t.Fatalf("FindSessionByID error: %v", err)
}
if got == nil || got.ID != "abc" {
t.Fatalf("got %+v, want exact abc", got)
}
}

func TestValidateSessionSortRejectsUnknownSort(t *testing.T) {
if err := ValidateSessionSort("bogus"); err == nil {
t.Fatal("ValidateSessionSort(bogus) returned nil, want error")
}
}
Loading
Loading