From 3ef3c92f813e9e35242338ab89904530006ca43a Mon Sep 17 00:00:00 2001 From: Andy Tran Date: Sun, 8 Mar 2026 22:45:02 -0400 Subject: [PATCH 1/2] feat: improve export defaults, selective tool truncation, and hints Default export no longer truncates conversation text. Tool results are truncated independently (--max-tool-chars, default 2000) while conversation text stays full. --full now acts as "show everything" (no truncation + include tool results). Contextual hints on stderr guide users to relevant flags when content is skipped or truncated. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 14 +++ internal/app/app_test.go | 151 +++++++++++++++++++++++++++++++++ internal/app/export.go | 116 ++++++++++++++++++------- internal/output/format.go | 17 ++++ internal/output/format_test.go | 21 +++++ internal/session/parse.go | 8 +- 6 files changed, 291 insertions(+), 36 deletions(-) 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/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" { From bddbe12e4742d0464042f9ff6e9a10d94318a3b2 Mon Sep 17 00:00:00 2001 From: Andy Tran Date: Sun, 8 Mar 2026 22:54:13 -0400 Subject: [PATCH 2/2] fix: recreate missing project directory on resume When resuming a session whose project directory no longer exists (e.g. renamed or deleted), create the directory automatically instead of erroring out. This lets users resume sessions to review history even when the original project path is gone. Co-Authored-By: Claude Opus 4.6 --- internal/app/resume.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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) } }