From 021fc26021e4cf8e4e8ee369174cfaed7b6d3307 Mon Sep 17 00:00:00 2001
From: Jon Gallant <2163001+jongio@users.noreply.github.com>
Date: Sun, 12 Jul 2026 11:42:25 -0700
Subject: [PATCH] feat(export): add HTML output format for session export
Add `--format html` to `dispatch export`, alongside the existing md and
json formats. It writes a single self-contained HTML document with the
stylesheet inlined, so the file opens in any browser with no external
requests or accompanying assets.
RenderHTML mirrors the RenderMarkdown structure (metadata table,
conversation, checkpoints, files, references) and escapes every piece of
session and conversation text with html.EscapeString so stored content
cannot inject markup. Message bodies use white-space: pre-wrap to keep
their original formatting without a markdown renderer.
Closes #297
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
README.md | 3 +-
cmd/dispatch/export.go | 16 +++--
cmd/dispatch/export_test.go | 55 +++++++++++++++++
cmd/dispatch/main.go | 2 +-
internal/data/export.go | 114 ++++++++++++++++++++++++++++++++++
internal/data/export_test.go | 115 +++++++++++++++++++++++++++++++++++
6 files changed, 299 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index eaec257..127223a 100644
--- a/README.md
+++ b/README.md
@@ -248,11 +248,12 @@ 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 --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, and `--out
` to choose the destination directory. `--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, and `--out ` to choose the destination directory. `--stdout` and `--out` cannot be combined.
### Search (JSON)
diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go
index f9453ef..5a9fc11 100644
--- a/cmd/dispatch/export.go
+++ b/cmd/dispatch/export.go
@@ -24,7 +24,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
}
@@ -145,15 +145,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)
}
}
@@ -166,6 +169,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
}
@@ -177,8 +182,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 590d68f..cec7591 100644
--- a/cmd/dispatch/export_test.go
+++ b/cmd/dispatch/export_test.go
@@ -52,6 +52,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},
@@ -137,6 +138,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