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("| Field | Value |
\n")
+ fmt.Fprintf(&b, "| ID | %s |
\n", esc(s.ID))
+ fmt.Fprintf(&b, "| Folder | %s |
\n", esc(s.Cwd))
+ if s.Repository != "" {
+ fmt.Fprintf(&b, "| Repository | %s |
\n", esc(s.Repository))
+ }
+ if s.Branch != "" {
+ fmt.Fprintf(&b, "| Branch | %s |
\n", esc(s.Branch))
+ }
+ fmt.Fprintf(&b, "| Created | %s |
\n", esc(s.CreatedAt))
+ fmt.Fprintf(&b, "| Last Active | %s |
\n", esc(s.LastActiveAt))
+ fmt.Fprintf(&b, "| Turns | %d |
\n", s.TurnCount)
+ fmt.Fprintf(&b, "| Files | %d |
\n", s.FileCount)
+ b.WriteString("
\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")
+ seen := make(map[string]struct{})
+ for _, f := range detail.Files {
+ if _, ok := seen[f.FilePath]; ok {
+ continue
+ }
+ seen[f.FilePath] = struct{}{}
+ fmt.Fprintf(&b, "%s (%s) \n", esc(f.FilePath), esc(f.ToolName))
+ }
+ b.WriteString("
\n")
+ }
+
+ // References.
+ if len(detail.Refs) > 0 {
+ b.WriteString("References
\n\n")
+ seen := make(map[string]struct{})
+ for _, ref := range detail.Refs {
+ key := ref.RefType + ":" + ref.RefValue
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ fmt.Fprintf(&b, "- %s: %s
\n", esc(ref.RefType), esc(ref.RefValue))
+ }
+ b.WriteString("
\n")
+ }
+
+ b.WriteString("\n