diff --git a/CHANGELOG.md b/CHANGELOG.md index 4075af2..7161ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- `export`: default output is now full (no truncation) — conversation text is shown complete +- `export`: truncation uses `[+N chars]` indicator so users know content was cut +- `export`: tool results (with `--include-tool-results`) are truncated independently via `--max-tool-chars` (default 2000) while conversation text stays full +- `export`: `--full` now also includes tool results (acts as "show everything") + +### Added + +- `export`: `--short` flag for compact output (truncates messages to 500 chars) +- `export`: `--max-tool-chars` flag to control tool result truncation separately +- `export`: contextual hints on stderr when tool blocks are skipped or messages are truncated, suggesting relevant flags +- `CCT_NO_HINTS` environment variable to suppress stderr hints + ## [0.4.0] - 2026-03-06 ### Added diff --git a/internal/app/app_test.go b/internal/app/app_test.go index aaf55e9..8d482c6 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" ) @@ -336,6 +337,156 @@ func TestExportCmd(t *testing.T) { } } +func TestExportCmd_NoTruncationByDefault(t *testing.T) { + home := setupFixtures(t) + + // Create a session with a long assistant message (>500 chars). + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-myproject") + longText := strings.Repeat("word ", 200) // 1000 chars + sessionLines := []string{ + `{"type":"user","message":{"role":"user","content":"tell me something long"},"cwd":"/Users/test/myproject","sessionId":"long1234-5678-9abc-def0-222222222222","timestamp":"2026-03-01T10:00:00Z"}`, + fmt.Sprintf(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"%s"}]},"timestamp":"2026-03-01T10:00:05Z"}`, longText), + } + writeLines(t, filepath.Join(projDir, "long1234-5678-9abc-def0-222222222222.jsonl"), sessionLines) + + globals := &Globals{} + cmd := &ExportCmd{ID: "long1234", Role: "user,assistant"} + + out := captureStdout(t, func() { + if err := cmd.Run(globals); err != nil { + t.Fatal(err) + } + }) + + if !strings.Contains(out, longText) { + t.Error("expected full message text without truncation by default") + } +} + +func TestExportCmd_Short(t *testing.T) { + home := setupFixtures(t) + + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-myproject") + longText := strings.Repeat("word ", 200) + sessionLines := []string{ + `{"type":"user","message":{"role":"user","content":"tell me something long"},"cwd":"/Users/test/myproject","sessionId":"short123-5678-9abc-def0-333333333333","timestamp":"2026-03-01T10:00:00Z"}`, + fmt.Sprintf(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"%s"}]},"timestamp":"2026-03-01T10:00:05Z"}`, longText), + } + writeLines(t, filepath.Join(projDir, "short123-5678-9abc-def0-333333333333.jsonl"), sessionLines) + + globals := &Globals{} + cmd := &ExportCmd{ID: "short123", Role: "user,assistant", Short: true} + + out := captureStdout(t, func() { + if err := cmd.Run(globals); err != nil { + t.Fatal(err) + } + }) + + if strings.Contains(out, longText) { + t.Error("expected truncated output with --short flag") + } + if !strings.Contains(out, "[+") { + t.Error("expected truncation count indicator") + } +} + +func TestExportCmd_ToolResultTruncation(t *testing.T) { + home := setupFixtures(t) + + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-myproject") + longToolOutput := strings.Repeat("line of tool output ", 150) // 3000 chars + sessionLines := []string{ + fmt.Sprintf(`{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"%s"},{"type":"text","text":"read something"}]},"cwd":"/Users/test/myproject","sessionId":"tool1234-5678-9abc-def0-444444444444","timestamp":"2026-03-01T10:00:00Z"}`, longToolOutput), + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Here is the file content."}]},"timestamp":"2026-03-01T10:00:05Z"}`, + } + writeLines(t, filepath.Join(projDir, "tool1234-5678-9abc-def0-444444444444.jsonl"), sessionLines) + + globals := &Globals{} + cmd := &ExportCmd{ID: "tool1234", Role: "user,assistant", IncludeToolResults: true, MaxToolChars: 100} + + out := captureStdout(t, func() { + if err := cmd.Run(globals); err != nil { + t.Fatal(err) + } + }) + + // Conversation text should be untruncated. + if !strings.Contains(out, "read something") { + t.Error("conversation text should not be truncated") + } + // Tool result block should be truncated with indicator. + if !strings.Contains(out, "[+") { + t.Error("expected tool result truncation with count indicator") + } + if strings.Contains(out, longToolOutput) { + t.Error("tool result output should be truncated") + } +} + +func TestExportCmd_FullIncludesToolResults(t *testing.T) { + home := setupFixtures(t) + + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-myproject") + sessionLines := []string{ + `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"file contents here"},{"type":"text","text":"check this"}]},"cwd":"/Users/test/myproject","sessionId":"full1234-5678-9abc-def0-555555555555","timestamp":"2026-03-01T10:00:00Z"}`, + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Got it."}]},"timestamp":"2026-03-01T10:00:05Z"}`, + } + writeLines(t, filepath.Join(projDir, "full1234-5678-9abc-def0-555555555555.jsonl"), sessionLines) + + globals := &Globals{} + cmd := &ExportCmd{ID: "full1234", Role: "user,assistant", Full: true} + + out := captureStdout(t, func() { + if err := cmd.Run(globals); err != nil { + t.Fatal(err) + } + }) + + if !strings.Contains(out, "file contents here") { + t.Error("--full should include tool result content") + } +} + +func TestExportCmd_HintOnSkippedToolBlocks(t *testing.T) { + home := setupFixtures(t) + + projDir := filepath.Join(home, ".claude", "projects", "-Users-test-myproject") + sessionLines := []string{ + `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"big output"},{"type":"text","text":"check this"}]},"cwd":"/Users/test/myproject","sessionId":"hint1234-5678-9abc-def0-666666666666","timestamp":"2026-03-01T10:00:00Z"}`, + `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."},{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]},"timestamp":"2026-03-01T10:00:05Z"}`, + } + writeLines(t, filepath.Join(projDir, "hint1234-5678-9abc-def0-666666666666.jsonl"), sessionLines) + + // Capture stderr for hints. + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + globals := &Globals{} + cmd := &ExportCmd{ID: "hint1234", Role: "user,assistant"} + + captureStdout(t, func() { + if err := cmd.Run(globals); err != nil { + t.Fatal(err) + } + }) + + _ = w.Close() + os.Stderr = oldStderr + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + hints := buf.String() + + if !strings.Contains(hints, "tool block(s) skipped") { + t.Errorf("expected hint about skipped tool blocks, got: %q", hints) + } + if !strings.Contains(hints, "--include-tool-results") { + t.Errorf("expected hint to mention --include-tool-results flag, got: %q", hints) + } +} + func TestExportCmd_ToFile(t *testing.T) { setupFixtures(t) diff --git a/internal/app/export.go b/internal/app/export.go index 527c103..0347cd2 100644 --- a/internal/app/export.go +++ b/internal/app/export.go @@ -14,11 +14,13 @@ import ( type ExportCmd struct { ID string `arg:"" help:"Session ID or prefix"` - Full bool `help:"Include full message text (no truncation)"` + Full bool `help:"Show everything (no truncation, include tool results)"` + Short bool `help:"Compact output (truncate messages to 500 chars)"` Output string `short:"o" help:"Output file (default: stdout)"` Role string `short:"r" help:"Filter by role (comma-separated: user,assistant)" default:"user,assistant"` Limit int `short:"n" help:"Last N messages (0=all)" default:"0"` - MaxChars int `help:"Truncate message text to N chars (0=no limit)" default:"500" name:"max-chars"` + MaxChars int `help:"Truncate conversation text to N chars (0=no limit)" default:"0" name:"max-chars"` + MaxToolChars int `help:"Truncate tool result text to N chars (0=no limit)" default:"2000" name:"max-tool-chars"` IncludeToolResults bool `help:"Include tool result content" name:"include-tool-results"` Search string `short:"s" help:"Filter messages containing this text (case-insensitive)"` } @@ -30,28 +32,59 @@ func (cmd *ExportCmd) Run(globals *Globals) error { } maxChars := cmd.MaxChars + maxToolChars := cmd.MaxToolChars + includeToolResults := cmd.IncludeToolResults if cmd.Full { maxChars = 0 + maxToolChars = 0 + includeToolResults = true + } + if cmd.Short { + maxChars = 500 } roles := parseRoles(cmd.Role) if globals.JSON { - return cmd.exportJSON(match, roles, maxChars, cmd.Search) + return cmd.exportJSON(match, roles, maxChars, maxToolChars, includeToolResults, cmd.Search) } - md, err := renderMarkdown(match, roles, maxChars, cmd.Limit, cmd.IncludeToolResults, cmd.Search) + md, stats, err := renderMarkdown(match, roles, maxChars, maxToolChars, cmd.Limit, includeToolResults, cmd.Search) if err != nil { return err } if cmd.Output != "" { - return os.WriteFile(cmd.Output, []byte(md), 0o600) + err = os.WriteFile(cmd.Output, []byte(md), 0o600) + if err != nil { + return err + } + printHints(stats) + return nil } + fmt.Print(md) + printHints(stats) return nil } +type exportStats struct { + toolBlocksSkipped int + messagesTruncated int +} + +func printHints(stats exportStats) { + if os.Getenv("CCT_NO_HINTS") != "" { + return + } + if stats.toolBlocksSkipped > 0 { + fmt.Fprintf(os.Stderr, "hint: %d tool block(s) skipped (use --include-tool-results or --full to include)\n", stats.toolBlocksSkipped) + } + if stats.messagesTruncated > 0 { + fmt.Fprintf(os.Stderr, "hint: %d message(s) truncated (use --full for complete output)\n", stats.messagesTruncated) + } +} + func parseRoles(roleStr string) map[string]bool { roles := make(map[string]bool) for _, r := range strings.Split(roleStr, ",") { @@ -63,10 +96,10 @@ func parseRoles(roleStr string) map[string]bool { return roles } -func renderMarkdown(s *session.Session, roles map[string]bool, maxChars, limit int, includeToolResults bool, searchFilter string) (string, error) { +func renderMarkdown(s *session.Session, roles map[string]bool, maxChars, maxToolChars, limit int, includeToolResults bool, searchFilter string) (string, exportStats, error) { f, err := os.Open(s.FilePath) if err != nil { - return "", fmt.Errorf("cannot open session file: %w", err) + return "", exportStats{}, fmt.Errorf("cannot open session file: %w", err) } defer func() { _ = f.Close() }() @@ -85,7 +118,7 @@ func renderMarkdown(s *session.Session, roles map[string]bool, maxChars, limit i fmt.Fprintf(&b, "- **Messages**: %d\n", s.MessageCount) b.WriteString("\n---\n\n") - messages := collectMessages(f, roles, includeToolResults, searchFilter) + messages, stats := collectMessages(f, roles, includeToolResults, searchFilter, maxToolChars) if limit > 0 && len(messages) > limit { messages = messages[len(messages)-limit:] @@ -94,7 +127,8 @@ func renderMarkdown(s *session.Session, roles map[string]bool, maxChars, limit i for _, msg := range messages { text := msg.text if maxChars > 0 && len(text) > maxChars { - text = output.Truncate(text, maxChars) + text = output.TruncateWithCount(text, maxChars) + stats.messagesTruncated++ } if msg.role == "user" { @@ -106,7 +140,7 @@ func renderMarkdown(s *session.Session, roles map[string]bool, maxChars, limit i b.WriteString("\n\n---\n\n") } - return b.String(), nil + return b.String(), stats, nil } type exportMessage struct { @@ -115,9 +149,10 @@ type exportMessage struct { timestamp time.Time } -func collectMessages(r io.Reader, roles map[string]bool, includeToolResults bool, searchFilter string) []exportMessage { +func collectMessages(r io.Reader, roles map[string]bool, includeToolResults bool, searchFilter string, maxToolChars int) ([]exportMessage, exportStats) { scanner := session.NewJSONLScanner(r) var messages []exportMessage + var stats exportStats searchLower := strings.ToLower(searchFilter) for scanner.Scan() { @@ -136,12 +171,9 @@ func collectMessages(r io.Reader, roles map[string]bool, includeToolResults bool continue } - var text string - if includeToolResults { - text = session.ExtractPromptText(obj) - } else { - text = extractTextNoToolResults(obj) - } + text, skipped := extractContent(obj, includeToolResults, maxToolChars) + stats.toolBlocksSkipped += skipped + if text == "" { continue } @@ -159,42 +191,62 @@ func collectMessages(r io.Reader, roles map[string]bool, includeToolResults bool }) } - return messages + return messages, stats } -// extractTextNoToolResults extracts message text but skips tool_result blocks. -func extractTextNoToolResults(obj map[string]any) string { +func extractContent(obj map[string]any, includeToolResults bool, maxToolChars int) (string, int) { msg, ok := obj["message"].(map[string]any) if !ok { - return "" + return "", 0 } content := msg["content"] if content == nil { - return "" + return "", 0 } if str, ok := content.(string); ok { - return str + return str, 0 } arr, ok := content.([]any) if !ok { - return "" + return "", 0 } var parts []string + skipped := 0 for _, item := range arr { block, ok := item.(map[string]any) if !ok { continue } blockType, _ := block["type"].(string) - // Skip tool_result AND the types already skipped by ExtractTextFromContent - if blockType == "tool_result" || blockType == "tool_use" || blockType == "thinking" || blockType == "redacted_thinking" || blockType == "image" || blockType == "document" { + if session.SkipTypes[blockType] { + continue + } + + isToolBlock := blockType == "tool_result" || blockType == "tool_use" + + if isToolBlock && !includeToolResults { + skipped++ + continue + } + + var text string + if isToolBlock { + text = session.ExtractTextFromContent(item) + } else if t, ok := block["text"].(string); ok { + text = t + } + + if text == "" { continue } - if text, ok := block["text"].(string); ok && text != "" { - parts = append(parts, text) + + if isToolBlock && maxToolChars > 0 && len(text) > maxToolChars { + text = output.TruncateWithCount(text, maxToolChars) } + + parts = append(parts, text) } - return strings.Join(parts, " ") + return strings.Join(parts, " "), skipped } type exportJSONOutput struct { @@ -208,14 +260,14 @@ type exportJSONMessage struct { Text string `json:"text"` } -func (cmd *ExportCmd) exportJSON(s *session.Session, roles map[string]bool, maxChars int, searchFilter string) error { +func (cmd *ExportCmd) exportJSON(s *session.Session, roles map[string]bool, maxChars, maxToolChars int, includeToolResults bool, searchFilter string) error { f, err := os.Open(s.FilePath) if err != nil { return fmt.Errorf("cannot open session file: %w", err) } defer func() { _ = f.Close() }() - messages := collectMessages(f, roles, cmd.IncludeToolResults, searchFilter) + messages, _ := collectMessages(f, roles, includeToolResults, searchFilter, maxToolChars) if cmd.Limit > 0 && len(messages) > cmd.Limit { messages = messages[len(messages)-cmd.Limit:] @@ -225,7 +277,7 @@ func (cmd *ExportCmd) exportJSON(s *session.Session, roles map[string]bool, maxC for _, msg := range messages { text := msg.text if maxChars > 0 && len(text) > maxChars { - text = output.Truncate(text, maxChars) + text = output.TruncateWithCount(text, maxChars) } jm := exportJSONMessage{ Role: msg.role, diff --git a/internal/app/resume.go b/internal/app/resume.go index c391066..0c3b150 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -39,8 +39,14 @@ func (cmd *ResumeCmd) Run(globals *Globals) error { dir := match.ProjectPath if dir != "" { - if _, err := os.Stat(dir); os.IsNotExist(err) { - return fmt.Errorf("project directory no longer exists: %s\nUse --dry-run to see the command", dir) + if _, err := os.Stat(dir); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("cannot access project directory: %s: %w", dir, err) + } + if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil { + return fmt.Errorf("project directory missing and could not be created: %s: %w", dir, mkErr) + } + fmt.Fprintf(os.Stderr, "Created missing directory: %s\n", dir) } } diff --git a/internal/output/format.go b/internal/output/format.go index cd9864c..517c14b 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -35,6 +35,23 @@ func Truncate(s string, maxLen int) string { return s } +// TruncateWithCount truncates s to maxLen runes and appends a count of +// remaining characters, e.g. "some text... [+1,234 chars]". Unlike Truncate, +// it preserves newlines so that markdown structure is maintained. +func TruncateWithCount(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + remaining := len(runes) - maxLen + suffix := fmt.Sprintf("... [+%d chars]", remaining) + cut := maxLen - len(suffix) // suffix is ASCII + if cut < 0 { + cut = 0 + } + return string(runes[:cut]) + suffix +} + // HighlightKeyword returns the string with the first occurrence of keyword // highlighted (bold) and the surrounding text dimmed. Case-insensitive match. func HighlightKeyword(s, keyword string) string { diff --git a/internal/output/format_test.go b/internal/output/format_test.go index de513aa..30b752b 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -94,6 +94,27 @@ func TestHighlightKeyword_no_match_dims_entire_string(t *testing.T) { } } +func TestTruncateWithCount(t *testing.T) { + tests := []struct { + input string + max int + want string + }{ + {"short", 100, "short"}, + {"hello world this is a long message that should be truncated", 30, "hello world thi... [+29 chars]"}, + {"preserves\nnewlines\nin output", 100, "preserves\nnewlines\nin output"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := TruncateWithCount(tt.input, tt.max) + if got != tt.want { + t.Errorf("TruncateWithCount(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.want) + } + }) + } +} + func ExampleExtractSnippet() { text := "The quick brown fox jumps over the lazy dog" snippet := ExtractSnippet(text, "fox", 30) diff --git a/internal/session/parse.go b/internal/session/parse.go index 8db2561..4b98c54 100644 --- a/internal/session/parse.go +++ b/internal/session/parse.go @@ -82,8 +82,8 @@ func ExtractPromptBlocks(obj map[string]any) []ContentBlock { return ExtractContentBlocks(msg["content"]) } -// skipTypes lists content block types that never contain searchable text. -var skipTypes = map[string]bool{ +// SkipTypes lists content block types that never contain searchable text. +var SkipTypes = map[string]bool{ "thinking": true, "redacted_thinking": true, "image": true, @@ -142,7 +142,7 @@ func extractBlocks(content any, depth int) []ContentBlock { func extractBlockEntries(block map[string]any, depth int) []ContentBlock { blockType, _ := block["type"].(string) - if skipTypes[blockType] { + if SkipTypes[blockType] { return nil } if blockType == "tool_use" { @@ -195,7 +195,7 @@ func extractText(content any, depth int) string { func extractBlockText(block map[string]any, depth int) string { blockType, _ := block["type"].(string) - if skipTypes[blockType] { + if SkipTypes[blockType] { return "" } if blockType == "tool_use" {