diff --git a/internal/app/export.go b/internal/app/export.go index 46cc09a..527c103 100644 --- a/internal/app/export.go +++ b/internal/app/export.go @@ -242,18 +242,17 @@ func (cmd *ExportCmd) exportJSON(s *session.Session, roles map[string]bool, maxC Messages: jsonMessages, } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - + var w io.Writer = os.Stdout if cmd.Output != "" { outFile, err := os.Create(cmd.Output) if err != nil { return err } defer func() { _ = outFile.Close() }() - enc = json.NewEncoder(outFile) - enc.SetIndent("", " ") + w = outFile } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") return enc.Encode(out) } diff --git a/internal/app/list.go b/internal/app/list.go index d6028ec..f4985fc 100644 --- a/internal/app/list.go +++ b/internal/app/list.go @@ -13,21 +13,22 @@ import ( type DefaultCmd struct{} func (cmd *DefaultCmd) Run(globals *Globals) error { - return listSessions(globals, "", 5, false, true) + return listSessions(globals, "", 5, false, true, false) } type ListCmd struct { Project string `short:"p" help:"Filter by project name"` Limit int `short:"n" help:"Max results" default:"15"` All bool `short:"a" help:"Show all results"` + Agents bool `help:"Include sub-agent sessions"` } func (cmd *ListCmd) Run(globals *Globals) error { - return listSessions(globals, cmd.Project, cmd.Limit, cmd.All, false) + return listSessions(globals, cmd.Project, cmd.Limit, cmd.All, false, cmd.Agents) } -func listSessions(globals *Globals, project string, limit int, showAll, compact bool) error { - sessions := session.ScanAll(project, false) +func listSessions(globals *Globals, project string, limit int, showAll, compact bool, includeAgents bool) error { + sessions := session.ScanAll(project, false, includeAgents) sort.Slice(sessions, func(i, j int) bool { return sessions[i].Modified.After(sessions[j].Modified) }) @@ -53,9 +54,23 @@ func listSessions(globals *Globals, project string, limit int, showAll, compact const maxResumeHints = 3 +func printResumeHints(sessions []*session.Session) { + hints := 0 + for _, s := range sessions { + if hints >= maxResumeHints { + break + } + if s.IsAgent { + continue + } + fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct resume %s", s.ShortID))) + hints++ + } +} + func printSessionTable(sessions []*session.Session, compact bool) { tbl := output.NewTable("", - output.Fixed("SESSION", 10), + output.Fixed("SESSION", 16), output.Flex("PROJECT", 30, 15), output.Fixed("BRANCH", 8), output.Fixed("AGE", 6), @@ -84,13 +99,7 @@ func printSessionTable(sessions []*session.Session, compact bool) { if !compact { fmt.Println() - n := len(sessions) - if n > maxResumeHints { - n = maxResumeHints - } - for _, s := range sessions[:n] { - fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct resume %s", s.ShortID))) - } + printResumeHints(sessions) } fmt.Println() } diff --git a/internal/app/plans.go b/internal/app/plans.go index 0078590..10bcee5 100644 --- a/internal/app/plans.go +++ b/internal/app/plans.go @@ -115,7 +115,6 @@ func (cmd *PlansSearchCmd) Run(globals *Globals) error { return nil } - // Apply limit if !cmd.All && cmd.Limit > 0 && len(matches) > cmd.Limit { total := len(matches) matches = matches[:cmd.Limit] diff --git a/internal/app/search.go b/internal/app/search.go index 32a55af..b5470d6 100644 --- a/internal/app/search.go +++ b/internal/app/search.go @@ -10,27 +10,55 @@ import ( "github.com/andyhtran/cct/internal/session" ) +var roleTag = map[string]string{ + "user": "[u]", + "assistant": "[a]", +} + +func formatMatchRole(m session.Match) string { + tag := roleTag[m.Role] + if tag == "" { + tag = "[?]" + } + if m.Source != "" { + tag = tag[:len(tag)-1] + ":" + m.Source + "]" + } + return output.Dim(tag) + " " + m.Snippet +} + type SearchCmd struct { Query string `arg:"" help:"Search query"` Project string `short:"p" help:"Filter by project name"` + Session string `short:"s" help:"Search within a specific session (ID or prefix)"` Limit int `short:"n" help:"Max results (0=no limit)" default:"25"` All bool `short:"a" help:"Show all results"` MaxMatches int `short:"m" help:"Max matches per session" default:"3"` + Context int `short:"C" help:"Extra context characters for snippets" default:"0"` + NoAgents bool `help:"Exclude sub-agent sessions" name:"no-agents"` } func (cmd *SearchCmd) Run(globals *Globals) error { tbl := output.NewTable(cmd.Query, - output.Fixed("SESSION", 10), + output.Fixed("SESSION", 16), output.Flex("PROJECT", 25, 15), output.Fixed("AGE", 6), output.Flex("MATCH", 0, 30), ) - files := session.DiscoverFiles(cmd.Project) - if !globals.JSON && len(files) > 50 { - fmt.Fprintf(os.Stderr, "Searching %d sessions...\n", len(files)) + var files []string + if cmd.Session != "" { + s, err := session.FindByPrefix(cmd.Session) + if err != nil { + return err + } + files = []string{s.FilePath} + } else { + files = session.DiscoverFiles(cmd.Project, !cmd.NoAgents) + if !globals.JSON && len(files) > 50 { + fmt.Fprintf(os.Stderr, "Searching %d sessions...\n", len(files)) + } } - results := session.SearchFiles(files, cmd.Query, tbl.LastColWidth(), cmd.MaxMatches) + results := session.SearchFiles(files, cmd.Query, tbl.LastColWidth()+cmd.Context, cmd.MaxMatches) sort.Slice(results, func(i, j int) bool { return results[i].Session.Modified.After(results[j].Session.Modified) @@ -61,26 +89,29 @@ func (cmd *SearchCmd) Run(globals *Globals) error { for _, r := range results { s := r.Session + projectName := s.ProjectName + if s.IsAgent { + projectName += " (agent)" + } for i, m := range r.Matches { + display := formatMatchRole(m) if i == 0 { tbl.Row( - []string{s.ShortID, output.Truncate(s.ProjectName, tbl.ColWidth(1)), output.FormatAge(s.Modified), m}, + []string{s.ShortID, output.Truncate(projectName, tbl.ColWidth(1)), output.FormatAge(s.Modified), display}, []func(string) string{output.Dim, output.Bold, output.Dim, nil}, ) } else { - tbl.Continuation(m) + tbl.Continuation(display) } } } fmt.Println() - n := len(results) - if n > maxResumeHints { - n = maxResumeHints - } - for _, r := range results[:n] { - fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct resume %s", r.Session.ShortID))) + sessions := make([]*session.Session, len(results)) + for i, r := range results { + sessions[i] = r.Session } + printResumeHints(sessions) fmt.Println() return nil } diff --git a/internal/app/stats.go b/internal/app/stats.go index 5aa90ce..a068d6a 100644 --- a/internal/app/stats.go +++ b/internal/app/stats.go @@ -11,7 +11,9 @@ import ( "github.com/andyhtran/cct/internal/session" ) -type StatsCmd struct{} +type StatsCmd struct { + Agents bool `help:"Include sub-agent sessions"` +} type statsData struct { Total int `json:"total_sessions"` @@ -29,7 +31,7 @@ type projectStat struct { } func (cmd *StatsCmd) Run(globals *Globals) error { - files := session.DiscoverFiles("") + files := session.DiscoverFiles("", cmd.Agents) if !globals.JSON && len(files) > 50 { fmt.Fprintf(os.Stderr, "Scanning %d sessions...\n", len(files)) } diff --git a/internal/session/parse.go b/internal/session/parse.go index e6a3e67..8db2561 100644 --- a/internal/session/parse.go +++ b/internal/session/parse.go @@ -34,15 +34,12 @@ func NewJSONLScanner(r io.Reader) *bufio.Scanner { } func FastExtractType(line []byte) string { - // Check for top-level message types first - these values are unique to the - // top level and avoid confusion with nested types like "type":"message". if bytes.Contains(line, typeUser) { return "user" } if bytes.Contains(line, typeAssistant) { return "assistant" } - // Fall back to generic extraction for other types idx := bytes.Index(line, typePrefix) if idx < 0 { return "" @@ -77,17 +74,31 @@ func ExtractPromptText(obj map[string]any) string { return ExtractTextFromContent(msg["content"]) } +func ExtractPromptBlocks(obj map[string]any) []ContentBlock { + msg, ok := obj["message"].(map[string]any) + if !ok { + return nil + } + return ExtractContentBlocks(msg["content"]) +} + // skipTypes lists content block types that never contain searchable text. var skipTypes = map[string]bool{ "thinking": true, "redacted_thinking": true, "image": true, "document": true, - "tool_use": true, } const maxExtractDepth = 10 +// ContentBlock holds extracted text from a single content block along with its source. +// Source is empty for regular text blocks, or the tool name for tool_use blocks. +type ContentBlock struct { + Text string + Source string +} + // ExtractTextFromContent recursively extracts searchable text from message content. // Content can be a plain string, an array of content blocks, or a single block object. // Blocks may nest content via a "content" field (e.g. tool_result blocks). @@ -95,6 +106,63 @@ func ExtractTextFromContent(content any) string { return extractText(content, 0) } +// ExtractContentBlocks returns individual searchable blocks from message content, +// preserving the source (tool name for tool_use blocks, empty for text). +func ExtractContentBlocks(content any) []ContentBlock { + return extractBlocks(content, 0) +} + +func extractBlocks(content any, depth int) []ContentBlock { + if depth > maxExtractDepth || content == nil { + return nil + } + if str, ok := content.(string); ok { + if isBase64Like(str) { + return nil + } + return []ContentBlock{{Text: str}} + } + if obj, ok := content.(map[string]any); ok { + return extractBlockEntries(obj, depth) + } + arr, ok := content.([]any) + if !ok { + return nil + } + var blocks []ContentBlock + for _, item := range arr { + block, ok := item.(map[string]any) + if !ok { + continue + } + blocks = append(blocks, extractBlockEntries(block, depth)...) + } + return blocks +} + +func extractBlockEntries(block map[string]any, depth int) []ContentBlock { + blockType, _ := block["type"].(string) + if skipTypes[blockType] { + return nil + } + if blockType == "tool_use" { + text := extractToolUseText(block) + if text == "" { + return nil + } + name, _ := block["name"].(string) + return []ContentBlock{{Text: text, Source: name}} + } + var blocks []ContentBlock + if text, ok := block["text"].(string); ok && text != "" && !isBase64Like(text) { + blocks = append(blocks, ContentBlock{Text: text}) + } + if c, exists := block["content"]; exists { + blocks = append(blocks, extractBlocks(c, depth+1)...) + } + return blocks +} + func extractText(content any, depth int) string { if depth > maxExtractDepth || content == nil { return "" @@ -130,6 +198,9 @@ func extractBlockText(block map[string]any, depth int) string { if skipTypes[blockType] { return "" } + if blockType == "tool_use" { + return extractToolUseText(block) + } var parts []string if text, ok := block["text"].(string); ok && text != "" && !isBase64Like(text) { parts = append(parts, text) @@ -142,6 +213,21 @@ func extractBlockText(block map[string]any, depth int) string { return strings.Join(parts, " ") } +func extractToolUseText(block map[string]any) string { + var parts []string + if name, ok := block["name"].(string); ok { + parts = append(parts, name) + } + if input, ok := block["input"].(map[string]any); ok { + for _, v := range input { + if s, ok := v.(string); ok && s != "" && !isBase64Like(s) { + parts = append(parts, s) + } + } + } + return strings.Join(parts, " ") +} + func isBase64Like(s string) bool { return len(s) > 1000 && !strings.Contains(s[:1000], " ") } @@ -162,10 +248,6 @@ func extractUserMetadata(s *Session, obj map[string]any) bool { s.ProjectPath, _ = obj["cwd"].(string) s.ProjectName = filepath.Base(s.ProjectPath) s.GitBranch, _ = obj["gitBranch"].(string) - if sid, ok := obj["sessionId"].(string); ok && sid != "" { - s.ID = sid - s.ShortID = ShortID(sid) - } } if s.FirstPrompt == "" { s.FirstPrompt = ExtractPromptText(obj) @@ -200,6 +282,7 @@ func parseSession(path string, full bool) *Session { Modified: info.ModTime(), } s.ShortID = ShortID(s.ID) + s.IsAgent = IsAgentSession(s.ID) scanner := NewJSONLScanner(f) diff --git a/internal/session/parse_test.go b/internal/session/parse_test.go index f33d9fb..c3c0660 100644 --- a/internal/session/parse_test.go +++ b/internal/session/parse_test.go @@ -78,7 +78,7 @@ func TestExtractTextFromContent(t *testing.T) { } }) - t.Run("array content skips thinking/tool_use", func(t *testing.T) { + t.Run("skips thinking, extracts tool_use name+input only", func(t *testing.T) { content := []any{ map[string]any{"type": "thinking", "text": "hmm"}, map[string]any{"type": "text", "text": "visible"}, @@ -166,7 +166,6 @@ func TestExtractTextFromContent(t *testing.T) { } func TestExtractMetadata(t *testing.T) { - // Create a temp JSONL file dir := t.TempDir() path := filepath.Join(dir, "test-session-id.jsonl") @@ -193,8 +192,8 @@ func TestExtractMetadata(t *testing.T) { t.Fatal("expected non-nil session") } - if s.ID != "abc-123" { - t.Errorf("ID = %q, want %q", s.ID, "abc-123") + if s.ID != "test-session-id" { + t.Errorf("ID = %q, want %q (file-derived ID must be preserved)", s.ID, "test-session-id") } if s.ProjectPath != "/Users/test/project" { t.Errorf("ProjectPath = %q, want %q", s.ProjectPath, "/Users/test/project") @@ -248,8 +247,8 @@ func TestParseFullSession(t *testing.T) { if s.FirstPrompt != "first prompt" { t.Errorf("FirstPrompt = %q, want %q", s.FirstPrompt, "first prompt") } - if s.ID != "full-123" { - t.Errorf("ID = %q, want %q", s.ID, "full-123") + if s.ID != "test-full-id" { + t.Errorf("ID = %q, want %q (file-derived ID must be preserved)", s.ID, "test-full-id") } } @@ -296,8 +295,8 @@ func TestExtractUserMetadata(t *testing.T) { if s.GitBranch != "feature-x" { t.Errorf("GitBranch = %q, want %q", s.GitBranch, "feature-x") } - if s.ID != "real-session-id-1234" { - t.Errorf("ID = %q, want %q", s.ID, "real-session-id-1234") + if s.ID != "file-id" { + t.Errorf("ID = %q, want %q (sessionId should not override file-derived ID)", s.ID, "file-id") } if s.FirstPrompt != "implement auth" { t.Errorf("FirstPrompt = %q, want %q", s.FirstPrompt, "implement auth") @@ -341,6 +340,26 @@ func TestExtractUserMetadata(t *testing.T) { } }) + t.Run("sub-agent sessionId does not override file-derived ID", func(t *testing.T) { + // Sub-agent files (agent-*.jsonl) contain a sessionId pointing to their + // parent session. The file-derived ID must be preserved to avoid duplicate + // ID collisions when multiple sub-agents share the same parent. + s := &Session{ID: "agent-19b8cb-fake-uuid", ShortID: "agent-19"} + obj := map[string]any{ + "cwd": "/Users/test/project", + "sessionId": "c8035fd7-parent-session-id", + "message": map[string]any{"role": "user", "content": "sub-agent task"}, + } + + extractUserMetadata(s, obj) + if s.ID != "agent-19b8cb-fake-uuid" { + t.Errorf("ID = %q, want %q (sub-agent ID must not be replaced by parent sessionId)", s.ID, "agent-19b8cb-fake-uuid") + } + if s.ShortID != "agent-19" { + t.Errorf("ShortID = %q, want %q", s.ShortID, "agent-19") + } + }) + t.Run("returns false when metadata is incomplete", func(t *testing.T) { s := &Session{} obj := map[string]any{ diff --git a/internal/session/scan.go b/internal/session/scan.go index d331454..15c959f 100644 --- a/internal/session/scan.go +++ b/internal/session/scan.go @@ -15,7 +15,7 @@ import ( // DiscoverFiles reads exactly one directory level deep under ~/.claude/projects/, // matching Claude Code's current storage layout: //.jsonl. -func DiscoverFiles(projectFilter string) []string { +func DiscoverFiles(projectFilter string, includeAgents bool) []string { dir := paths.ProjectsDir() var files []string entries, err := os.ReadDir(dir) @@ -45,6 +45,9 @@ func DiscoverFiles(projectFilter string) []string { } for _, f := range dirEntries { if !f.IsDir() && strings.HasSuffix(f.Name(), ".jsonl") && f.Name() != "sessions-index.json" { + if !includeAgents && strings.HasPrefix(f.Name(), "agent-") { + continue + } files = append(files, filepath.Join(dirPath, f.Name())) } } @@ -52,57 +55,16 @@ func DiscoverFiles(projectFilter string) []string { return files } -func ScanAll(projectFilter string, fullParse bool) []*Session { - files := DiscoverFiles(projectFilter) +func ScanAll(projectFilter string, fullParse bool, includeAgents bool) []*Session { + files := DiscoverFiles(projectFilter, includeAgents) return ScanFiles(files, fullParse) } -func ScanFiles(files []string, fullParse bool) []*Session { - numWorkers := runtime.NumCPU() - jobs := make(chan string, len(files)) - results := make(chan *Session, len(files)) - var wg sync.WaitGroup - - for i := 0; i < numWorkers; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for path := range jobs { - var s *Session - if fullParse { - s = ParseFullSession(path) - } else { - s = ExtractMetadata(path) - } - if s != nil { - results <- s - } - } - }() - } - - for _, f := range files { - jobs <- f - } - close(jobs) - - go func() { - wg.Wait() - close(results) - }() - - var sessions []*Session - for s := range results { - sessions = append(sessions, s) - } - return sessions -} - -func SearchFiles(files []string, keyword string, snippetWidth int, maxMatches int) []*SearchResult { - keyLower := strings.ToLower(keyword) +// parallelMap applies fn to each file path using a worker pool and collects non-nil results. +func parallelMap[T any](files []string, fn func(string) *T) []*T { numWorkers := runtime.NumCPU() jobs := make(chan string, len(files)) - results := make(chan *SearchResult, len(files)) + results := make(chan *T, len(files)) var wg sync.WaitGroup for i := 0; i < numWorkers; i++ { @@ -110,8 +72,7 @@ func SearchFiles(files []string, keyword string, snippetWidth int, maxMatches in go func() { defer wg.Done() for path := range jobs { - r := searchOneFile(path, keyLower, snippetWidth, maxMatches) - if r != nil { + if r := fn(path); r != nil { results <- r } } @@ -128,13 +89,28 @@ func SearchFiles(files []string, keyword string, snippetWidth int, maxMatches in close(results) }() - var out []*SearchResult + var out []*T for r := range results { out = append(out, r) } return out } +func ScanFiles(files []string, fullParse bool) []*Session { + parse := ExtractMetadata + if fullParse { + parse = ParseFullSession + } + return parallelMap(files, parse) +} + +func SearchFiles(files []string, keyword string, snippetWidth int, maxMatches int) []*SearchResult { + keyLower := strings.ToLower(keyword) + return parallelMap(files, func(path string) *SearchResult { + return searchOneFile(path, keyLower, snippetWidth, maxMatches) + }) +} + func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *SearchResult { f, err := os.Open(path) if err != nil { @@ -152,10 +128,18 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea Modified: info.ModTime(), } s.ShortID = ShortID(s.ID) + s.IsAgent = IsAgentSession(s.ID) + + terms := strings.Fields(keyLower) + isPhrase := len(terms) <= 1 scanner := NewJSONLScanner(f) - var matches []string + var matches []Match + var termSeen []bool + if !isPhrase { + termSeen = make([]bool, len(terms)) + } for scanner.Scan() { line := scanner.Bytes() @@ -178,19 +162,67 @@ func searchOneFile(path, keyLower string, snippetWidth int, maxMatches int) *Sea continue } - text := ExtractPromptText(obj) - if text == "" { + blocks := ExtractPromptBlocks(obj) + if len(blocks) == 0 { continue } - if strings.Contains(strings.ToLower(text), keyLower) { - snippet := output.ExtractSnippet(text, keyLower, snippetWidth) - matches = append(matches, snippet) + for _, block := range blocks { + if maxMatches > 0 && len(matches) >= maxMatches { + break + } + + text := block.Text + textLower := strings.ToLower(text) + + roleWidth := len(lineType) + 3 // "[x] " prefix + if block.Source != "" { + roleWidth += len(block.Source) + 1 // ":Tool" suffix + } + sw := snippetWidth - roleWidth + + if isPhrase { + if strings.Contains(textLower, keyLower) { + snippet := output.ExtractSnippet(text, keyLower, sw) + matches = append(matches, Match{Role: lineType, Source: block.Source, Snippet: snippet}) + } + continue + } + + // Multi-term: check which terms appear in this block. + var bestTerm string + for i, term := range terms { + if strings.Contains(textLower, term) { + termSeen[i] = true + if bestTerm == "" || len(term) > len(bestTerm) { + bestTerm = term + } + } + } + if bestTerm != "" { + snippet := output.ExtractSnippet(text, bestTerm, sw) + matches = append(matches, Match{Role: lineType, Source: block.Source, Snippet: snippet}) + } } } // scanner.Err() intentionally not checked — partial results are acceptable. - if len(matches) == 0 { + if isPhrase { + if len(matches) == 0 { + return nil + } + return &SearchResult{Session: s, Matches: matches} + } + + // Multi-term: only return results where ALL terms appeared somewhere in the session. + allSeen := true + for _, seen := range termSeen { + if !seen { + allSeen = false + break + } + } + if !allSeen || len(matches) == 0 { return nil } diff --git a/internal/session/scan_test.go b/internal/session/scan_test.go index 8259e54..3ab8729 100644 --- a/internal/session/scan_test.go +++ b/internal/session/scan_test.go @@ -50,40 +50,64 @@ func TestDiscoverFiles(t *testing.T) { }) t.Run("no filter", func(t *testing.T) { - files := DiscoverFiles("") + files := DiscoverFiles("", true) if len(files) != 3 { t.Errorf("expected 3 files, got %d", len(files)) } }) t.Run("project filter", func(t *testing.T) { - files := DiscoverFiles("myproject") + files := DiscoverFiles("myproject", true) if len(files) != 2 { t.Errorf("expected 2 files for myproject filter, got %d", len(files)) } }) t.Run("filter no match", func(t *testing.T) { - files := DiscoverFiles("nonexistent") + files := DiscoverFiles("nonexistent", true) if len(files) != 0 { t.Errorf("expected 0 files for nonexistent filter, got %d", len(files)) } }) t.Run("case insensitive", func(t *testing.T) { - files := DiscoverFiles("MyProject") + files := DiscoverFiles("MyProject", true) if len(files) != 2 { t.Errorf("expected 2 files for case-insensitive filter, got %d", len(files)) } }) } +func TestDiscoverFiles_AgentFiltering(t *testing.T) { + home := setupTestHome(t) + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-myproject") + writeSessionFile(t, projDir, "sess-aaa", []string{ + `{"type":"user","message":{"role":"user","content":"hello"},"cwd":"/test"}`, + }) + writeSessionFile(t, projDir, "agent-12345678", []string{ + `{"type":"user","message":{"role":"user","content":"agent task"},"cwd":"/test"}`, + }) + + t.Run("excludes agents", func(t *testing.T) { + files := DiscoverFiles("", false) + if len(files) != 1 { + t.Errorf("expected 1 file (no agents), got %d", len(files)) + } + }) + + t.Run("includes agents", func(t *testing.T) { + files := DiscoverFiles("", true) + if len(files) != 2 { + t.Errorf("expected 2 files (with agents), got %d", len(files)) + } + }) +} + func TestDiscoverFiles_MissingDir(t *testing.T) { home := setupTestHome(t) - // Don't create the projects directory. _ = home - files := DiscoverFiles("") + files := DiscoverFiles("", true) if files != nil { t.Errorf("expected nil for missing dir, got %v", files) } @@ -98,7 +122,7 @@ func TestScanAll(t *testing.T) { }) t.Run("quick scan", func(t *testing.T) { - sessions := ScanAll("", false) + sessions := ScanAll("", false, true) if len(sessions) != 1 { t.Fatalf("expected 1 session, got %d", len(sessions)) } @@ -115,7 +139,7 @@ func TestScanAll(t *testing.T) { }) t.Run("full parse", func(t *testing.T) { - sessions := ScanAll("", true) + sessions := ScanAll("", true, true) if len(sessions) != 1 { t.Fatalf("expected 1 session, got %d", len(sessions)) } @@ -126,11 +150,11 @@ func TestScanAll(t *testing.T) { }) t.Run("with project filter", func(t *testing.T) { - sessions := ScanAll("proj", false) + sessions := ScanAll("proj", false, true) if len(sessions) != 1 { t.Fatalf("expected 1 session, got %d", len(sessions)) } - sessions = ScanAll("nonexistent", false) + sessions = ScanAll("nonexistent", false, true) if len(sessions) != 0 { t.Errorf("expected 0 sessions for nonexistent filter, got %d", len(sessions)) } @@ -149,7 +173,7 @@ func TestSearchFiles(t *testing.T) { `{"type":"user","message":{"role":"user","content":"add authentication"},"cwd":"/test","sessionId":"srch-002","timestamp":"2026-01-11T08:00:00Z"}`, }) - files := DiscoverFiles("") + files := DiscoverFiles("", true) t.Run("keyword found", func(t *testing.T) { results := SearchFiles(files, "database", 80, 3) @@ -185,7 +209,7 @@ func TestSearchFiles_ToolResult(t *testing.T) { `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"Read","input":{}},{"type":"tool_result","tool_use_id":"tu1","content":"database_url=postgres://localhost"}]},"timestamp":"2026-01-10T08:00:05Z"}`, }) - files := DiscoverFiles("") + files := DiscoverFiles("", true) t.Run("finds text in tool_result content", func(t *testing.T) { results := SearchFiles(files, "postgres", 80, 3) @@ -197,11 +221,74 @@ func TestSearchFiles_ToolResult(t *testing.T) { } }) - t.Run("still skips tool_use", func(t *testing.T) { - // "Read" only appears inside tool_use which should be skipped + t.Run("raw JSON not searchable", func(t *testing.T) { + // Raw JSON like "name":"Read" is not in the extracted text (extracted as "Read /path/...") results := SearchFiles(files, "\"name\":\"Read\"", 80, 3) if len(results) != 0 { - t.Errorf("expected 0 results for tool_use content, got %d", len(results)) + t.Errorf("expected 0 results for raw JSON, got %d", len(results)) + } + }) +} + +func TestSearchFiles_ToolUse(t *testing.T) { + home := setupTestHome(t) + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-proj") + + writeSessionFile(t, projDir, "tool-use-001", []string{ + `{"type":"user","message":{"role":"user","content":"show me the file"},"cwd":"/test","sessionId":"tool-use-001","timestamp":"2026-01-10T08:00:00Z"}`, + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"Read","input":{"file_path":"/path/to/config.go"}},{"type":"tool_result","tool_use_id":"tu1","content":"package config"}]},"timestamp":"2026-01-10T08:00:05Z"}`, + }) + + files := DiscoverFiles("", true) + + t.Run("finds file path in tool_use input", func(t *testing.T) { + results := SearchFiles(files, "config.go", 80, 3) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if len(results[0].Matches) == 0 { + t.Fatal("expected at least 1 match snippet") + } + m := results[0].Matches[0] + if m.Source != "Read" { + t.Errorf("Source = %q, want %q", m.Source, "Read") + } + }) + + t.Run("finds tool name", func(t *testing.T) { + results := SearchFiles(files, "Read", 80, 3) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Matches[0].Source != "Read" { + t.Errorf("Source = %q, want %q", results[0].Matches[0].Source, "Read") + } + }) + + t.Run("finds bash command", func(t *testing.T) { + home2 := setupTestHome(t) + projDir2 := filepath.Join(home2, ".claude", "projects", "-Users-test-proj") + writeSessionFile(t, projDir2, "tool-use-002", []string{ + `{"type":"user","message":{"role":"user","content":"compile the project"},"cwd":"/test","sessionId":"tool-use-002","timestamp":"2026-01-10T08:00:00Z"}`, + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu2","name":"Bash","input":{"command":"go build ./..."}}]},"timestamp":"2026-01-10T08:00:05Z"}`, + }) + files2 := DiscoverFiles("", true) + results := SearchFiles(files2, "go build", 80, 3) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Matches[0].Source != "Bash" { + t.Errorf("Source = %q, want %q", results[0].Matches[0].Source, "Bash") + } + }) + + t.Run("text match has empty source", func(t *testing.T) { + results := SearchFiles(files, "show me the file", 80, 3) + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Matches[0].Source != "" { + t.Errorf("Source = %q, want empty for text match", results[0].Matches[0].Source) } }) } @@ -219,7 +306,7 @@ func TestSearchFiles_MaxMatches(t *testing.T) { } writeSessionFile(t, projDir, "srch-max", lines) - files := DiscoverFiles("") + files := DiscoverFiles("", true) results := SearchFiles(files, "keyword", 80, 3) if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) diff --git a/internal/session/session.go b/internal/session/session.go index eb58563..9ef35b3 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -18,6 +18,7 @@ var ( type Session struct { ID string `json:"id"` ShortID string `json:"short_id"` + IsAgent bool `json:"is_agent"` ProjectPath string `json:"project_path"` ProjectName string `json:"project_name"` GitBranch string `json:"git_branch"` @@ -28,9 +29,15 @@ type Session struct { MessageCount int `json:"message_count"` } +type Match struct { + Role string `json:"role"` + Source string `json:"source,omitempty"` + Snippet string `json:"snippet"` +} + type SearchResult struct { Session *Session `json:"session"` - Matches []string `json:"matches"` + Matches []Match `json:"matches"` } func ExtractIDFromFilename(path string) string { @@ -38,7 +45,14 @@ func ExtractIDFromFilename(path string) string { return strings.TrimSuffix(base, ".jsonl") } +func IsAgentSession(id string) bool { + return strings.HasPrefix(id, "agent-") +} + func ShortID(id string) string { + if IsAgentSession(id) { + return id + } if len(id) >= 8 { return id[:8] } @@ -46,7 +60,7 @@ func ShortID(id string) string { } func FindByPrefix(prefix string) (*Session, error) { - sessions := ScanAll("", false) + sessions := ScanAll("", false, true) var matches []*Session for _, s := range sessions { if s.ID == prefix || strings.HasPrefix(s.ID, prefix) || s.ShortID == prefix { @@ -73,24 +87,8 @@ func FindByPrefixFull(prefix string) (*Session, error) { if err != nil { return nil, err } - - full := ParseFullSession(s.FilePath) - if full != nil { - s.MessageCount = full.MessageCount - if full.FirstPrompt != "" { - s.FirstPrompt = full.FirstPrompt - } - if full.ProjectPath != "" { - s.ProjectPath = full.ProjectPath - s.ProjectName = full.ProjectName - } - if full.GitBranch != "" { - s.GitBranch = full.GitBranch - } - if !full.Created.IsZero() { - s.Created = full.Created - } + if full := ParseFullSession(s.FilePath); full != nil { + return full, nil } - return s, nil } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index dda3ef6..4cb9b71 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -127,6 +127,78 @@ func TestFindByPrefixFull(t *testing.T) { } } +func TestIsAgentSession(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {"agent-12345678", true}, + {"agent-abcdef12", true}, + {"agent-", true}, + {"aaaa1111-2222-3333-4444-555555555555", false}, + {"", false}, + {"agents-not-an-agent", false}, + } + for _, tt := range tests { + if got := IsAgentSession(tt.id); got != tt.want { + t.Errorf("IsAgentSession(%q) = %v, want %v", tt.id, got, tt.want) + } + } +} + +func TestShortID_Agent(t *testing.T) { + tests := []struct { + id string + want string + }{ + {"agent-12345678", "agent-12345678"}, + {"agent-ab", "agent-ab"}, + {"aaaa1111-2222-3333-4444-555555555555", "aaaa1111"}, + {"short", "short"}, + } + for _, tt := range tests { + if got := ShortID(tt.id); got != tt.want { + t.Errorf("ShortID(%q) = %q, want %q", tt.id, got, tt.want) + } + } +} + +func TestFindByPrefix_Agent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-proj") + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatal(err) + } + + for _, s := range []struct{ id, content string }{ + {"sess-1111-2222-3333-4444-555555555555", `{"type":"user","message":{"role":"user","content":"regular"},"cwd":"/test"}`}, + {"agent-abcdef12", `{"type":"user","message":{"role":"user","content":"agent task"},"cwd":"/test"}`}, + } { + path := filepath.Join(projDir, s.id+".jsonl") + if err := os.WriteFile(path, []byte(s.content+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + s, err := FindByPrefix("agent-abcdef12") + if err != nil { + t.Fatalf("FindByPrefix agent: %v", err) + } + if !s.IsAgent { + t.Error("expected IsAgent=true for agent session") + } + + s, err = FindByPrefix("sess-1111") + if err != nil { + t.Fatalf("FindByPrefix regular: %v", err) + } + if s.IsAgent { + t.Error("expected IsAgent=false for regular session") + } +} + func TestExtractIDFromFilename(t *testing.T) { tests := []struct { path string