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
9 changes: 4 additions & 5 deletions internal/app/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
33 changes: 21 additions & 12 deletions internal/app/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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),
Expand Down Expand Up @@ -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()
}
1 change: 0 additions & 1 deletion internal/app/plans.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
57 changes: 44 additions & 13 deletions internal/app/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
6 changes: 4 additions & 2 deletions internal/app/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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))
}
Expand Down
99 changes: 91 additions & 8 deletions internal/session/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -77,24 +74,95 @@ 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).
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 ""
Expand Down Expand Up @@ -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)
Expand All @@ -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], " ")
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
Loading