diff --git a/README.md b/README.md index 0213e4e..f1f55a2 100644 --- a/README.md +++ b/README.md @@ -252,12 +252,13 @@ Save a full session (metadata and the complete conversation) to a file with `dis ```sh dispatch export 0a1b2c3d dispatch export 0a1b2c3d --format json +dispatch export 0a1b2c3d --format html dispatch export 0a1b2c3d --stdout dispatch export 0a1b2c3d --redact --stdout dispatch export 0a1b2c3d --out ./exports ``` -By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output, `--stdout` to print to the terminal instead of writing a file, `--out ` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined. +By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output or `--format html` for a self-contained web page you can open in a browser (styles are inlined, so there are no external files to manage). Use `--stdout` to print to the terminal instead of writing a file, `--out ` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined. ### Search (JSON) diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go index 5f5ffad..ea8fa65 100644 --- a/cmd/dispatch/export.go +++ b/cmd/dispatch/export.go @@ -25,7 +25,7 @@ var ( // exportOptions holds the parsed flags for the export command. type exportOptions struct { id string - format string // "md" or "json" + format string // "md", "json", or "html" stdout bool outDir string redact bool @@ -153,15 +153,18 @@ func parseExportArgs(args []string) (exportOptions, error) { return opts, nil } -// normalizeExportFormat maps a user-facing format string to "md" or "json". +// normalizeExportFormat maps a user-facing format string to "md", "json", or +// "html". func normalizeExportFormat(format string) (string, error) { switch strings.ToLower(format) { case "md", "markdown": return "md", nil case "json": return "json", nil + case "html": + return "html", nil default: - return "", fmt.Errorf("invalid format %q (want md or json)", format) + return "", fmt.Errorf("invalid format %q (want md, json, or html)", format) } } @@ -220,6 +223,8 @@ func renderExport(detail *data.SessionDetail, format string) (string, error) { return "", fmt.Errorf("encoding session as JSON: %w", err) } return string(b) + "\n", nil + case "html": + return data.RenderHTML(detail), nil default: return data.RenderMarkdown(detail), nil } @@ -231,8 +236,11 @@ func writeExportFile(dir, id, format, content string) (string, error) { return "", fmt.Errorf("creating export directory: %w", err) } ext := "md" - if format == "json" { + switch format { + case "json": ext = "json" + case "html": + ext = "html" } path := filepath.Join(dir, data.SafeFilename(id)+"."+ext) if err := os.WriteFile(path, []byte(content), 0o600); err != nil { diff --git a/cmd/dispatch/export_test.go b/cmd/dispatch/export_test.go index 6657a28..ff8e06e 100644 --- a/cmd/dispatch/export_test.go +++ b/cmd/dispatch/export_test.go @@ -53,6 +53,7 @@ func TestParseExportArgs(t *testing.T) { }{ {name: "id only", args: []string{"export", "abc"}, wantID: "abc", wantFormat: "md"}, {name: "format json", args: []string{"export", "abc", "--format", "json"}, wantID: "abc", wantFormat: "json"}, + {name: "format html", args: []string{"export", "abc", "--format", "html"}, wantID: "abc", wantFormat: "html"}, {name: "format markdown alias", args: []string{"export", "abc", "--format=markdown"}, wantID: "abc", wantFormat: "md"}, {name: "short format", args: []string{"export", "-f", "json", "abc"}, wantID: "abc", wantFormat: "json"}, {name: "stdout", args: []string{"export", "abc", "--stdout"}, wantID: "abc", wantFormat: "md", wantStdout: true}, @@ -182,6 +183,60 @@ func TestRunExport_WritesFile(t *testing.T) { } } +func TestRunExport_StdoutHTML(t *testing.T) { + withExportDetail(t, func(string) (*data.SessionDetail, error) { return sampleDetail(), nil }) + + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "ses-001", "--stdout", "--format", "html"}); err != nil { + t.Fatalf("runExport: %v", err) + } + out := buf.String() + for _, want := range []string{"", "Session: Fix the widget", "\n", htmlExportStyle) + b.WriteString("\n\n") + + fmt.Fprintf(&b, "

Session: %s

\n", esc(s.Summary)) + + // Metadata. + b.WriteString("

Metadata

\n\n") + b.WriteString("\n") + fmt.Fprintf(&b, "\n", esc(s.ID)) + fmt.Fprintf(&b, "\n", esc(s.Cwd)) + if s.Repository != "" { + fmt.Fprintf(&b, "\n", esc(s.Repository)) + } + if s.Branch != "" { + fmt.Fprintf(&b, "\n", esc(s.Branch)) + } + fmt.Fprintf(&b, "\n", esc(s.CreatedAt)) + fmt.Fprintf(&b, "\n", esc(s.LastActiveAt)) + fmt.Fprintf(&b, "\n", s.TurnCount) + fmt.Fprintf(&b, "\n", s.FileCount) + b.WriteString("
FieldValue
ID%s
Folder%s
Repository%s
Branch%s
Created%s
Last Active%s
Turns%d
Files%d
\n") + + // Conversation. + if len(detail.Turns) > 0 { + b.WriteString("

Conversation

\n") + for _, turn := range detail.Turns { + if turn.UserMessage != "" { + b.WriteString("
\n
User
\n") + fmt.Fprintf(&b, "
%s
\n
\n", esc(turn.UserMessage)) + } + if turn.AssistantResponse != "" { + b.WriteString("
\n
Assistant
\n") + fmt.Fprintf(&b, "
%s
\n
\n", esc(turn.AssistantResponse)) + } + } + } + + // Checkpoints. + if len(detail.Checkpoints) > 0 { + b.WriteString("

Checkpoints

\n") + for _, cp := range detail.Checkpoints { + fmt.Fprintf(&b, "

%d. %s

\n", cp.CheckpointNumber, esc(cp.Title)) + if cp.Overview != "" { + fmt.Fprintf(&b, "

%s

\n", esc(cp.Overview)) + } + } + } + + // Files. + if len(detail.Files) > 0 { + b.WriteString("

Files Touched

\n\n") + } + + // References. + if len(detail.Refs) > 0 { + b.WriteString("

References

\n\n") + } + + b.WriteString("\n\n") + return b.String() +} + // ExportSession writes a session detail as Markdown to the given directory. // The file is named .md using a sanitized filename. Returns the // full path of the written file. diff --git a/internal/data/export_test.go b/internal/data/export_test.go index 0f3fdaf..2aad49a 100644 --- a/internal/data/export_test.go +++ b/internal/data/export_test.go @@ -152,6 +152,121 @@ func TestRenderMarkdown_FullDetail(t *testing.T) { } } +func TestRenderHTML_NilDetail(t *testing.T) { + got := RenderHTML(nil) + if got != "" { + t.Errorf("RenderHTML(nil) = %q, want empty string", got) + } +} + +func TestRenderHTML_FullDetail(t *testing.T) { + detail := &SessionDetail{ + Session: Session{ + ID: "test-id-123", + Cwd: "/home/user/project", + Repository: "owner/repo", + Branch: "main", + Summary: "Implement feature X", + CreatedAt: "2025-01-01T10:00:00Z", + LastActiveAt: "2025-01-01T12:00:00Z", + TurnCount: 3, + FileCount: 2, + }, + Turns: []Turn{ + {TurnIndex: 0, UserMessage: "Please add tests", AssistantResponse: "I'll add the tests now."}, + }, + Checkpoints: []Checkpoint{ + {CheckpointNumber: 1, Title: "Initial setup", Overview: "Created project scaffold."}, + }, + Files: []SessionFile{ + {FilePath: "src/main.go", ToolName: "edit"}, + {FilePath: "src/main.go", ToolName: "edit"}, // duplicate + }, + Refs: []SessionRef{ + {RefType: "commit", RefValue: "abc123"}, + {RefType: "commit", RefValue: "abc123"}, // duplicate + }, + } + + out := RenderHTML(detail) + + for _, want := range []string{ + "", + "