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("\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{ + "", + "