Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>` 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 <dir>` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined.

### Search (JSON)

Expand Down
16 changes: 12 additions & 4 deletions cmd/dispatch/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions cmd/dispatch/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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{"<!DOCTYPE html>", "<title>Session: Fix the widget</title>", "<style>"} {
if !strings.Contains(out, want) {
t.Errorf("html output missing %q, got:\n%s", want, out)
}
}
}

func TestRunExport_HTMLEscapesContent(t *testing.T) {
detail := sampleDetail()
detail.Session.Summary = "Fix <script>alert(1)</script>"
detail.Turns = []data.Turn{{UserMessage: "run <b>bold</b> & stuff", AssistantResponse: "ok"}}
withExportDetail(t, func(string) (*data.SessionDetail, error) { return detail, 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()
if strings.Contains(out, "<script>alert(1)</script>") {
t.Errorf("html output must escape raw script tags, got:\n%s", out)
}
if !strings.Contains(out, "&lt;script&gt;alert(1)&lt;/script&gt;") {
t.Errorf("html output missing escaped summary, got:\n%s", out)
}
if !strings.Contains(out, "run &lt;b&gt;bold&lt;/b&gt; &amp; stuff") {
t.Errorf("html output missing escaped message body, got:\n%s", out)
}
}

func TestRunExport_WritesHTMLFile(t *testing.T) {
withExportDetail(t, func(string) (*data.SessionDetail, error) { return sampleDetail(), nil })

dir := t.TempDir()
var buf bytes.Buffer
if err := runExport(&buf, []string{"export", "ses-001", "--out", dir, "--format", "html"}); err != nil {
t.Fatalf("runExport: %v", err)
}
path := filepath.Join(dir, "ses-001.html")
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected export file at %s: %v", path, err)
}
if !strings.Contains(buf.String(), path) {
t.Errorf("output should report the path %q, got %q", path, buf.String())
}
}

func TestRunExport_NotFound(t *testing.T) {
withExportDetail(t, func(string) (*data.SessionDetail, error) { return nil, nil })

Expand Down
2 changes: 1 addition & 1 deletion cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Config commands:
config path Print the config file path

Export flags:
--format md|json Output format (default md)
--format md|json|html Output format (default md)
--out <dir> Write to a directory instead of the exports folder
--stdout Print to stdout instead of writing a file
--redact Mask common secret patterns in the export
Expand Down
114 changes: 114 additions & 0 deletions internal/data/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package data

import (
"fmt"
"html"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -124,6 +125,119 @@ func RenderMarkdown(detail *SessionDetail) string {
return b.String()
}

// htmlExportStyle is the inline stylesheet for HTML exports. It is embedded in
// the document so the file renders without any external requests.
const htmlExportStyle = `body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#1f2328;background:#fff;margin:0;padding:2rem;max-width:56rem;margin-left:auto;margin-right:auto}
h1{font-size:1.6rem;margin:0 0 1rem}
h2{font-size:1.2rem;margin:2rem 0 .75rem;border-bottom:1px solid #d0d7de;padding-bottom:.3rem}
table{border-collapse:collapse;margin:0 0 1rem}
th,td{border:1px solid #d0d7de;padding:.35rem .6rem;text-align:left;vertical-align:top}
th{background:#f6f8fa}
.turn{border:1px solid #d0d7de;border-radius:6px;margin:0 0 1rem;overflow:hidden}
.turn .role{font-weight:600;padding:.4rem .75rem;background:#f6f8fa;border-bottom:1px solid #d0d7de}
.turn.user .role{background:#ddf4ff}
.turn .body{padding:.75rem;margin:0;white-space:pre-wrap;word-wrap:break-word;font-family:inherit}
code{background:#eff1f3;padding:.1rem .3rem;border-radius:4px}
ul{padding-left:1.25rem}`

// RenderHTML formats a SessionDetail as a single self-contained HTML document
// suitable for reading in a browser. The stylesheet is inlined so the file has
// no external references. All session and conversation text is escaped so
// content cannot inject markup.
func RenderHTML(detail *SessionDetail) string {
if detail == nil {
return ""
}

s := detail.Session
esc := html.EscapeString
var b strings.Builder

b.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
b.WriteString("<meta charset=\"utf-8\">\n")
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
fmt.Fprintf(&b, "<title>Session: %s</title>\n", esc(s.Summary))
fmt.Fprintf(&b, "<style>\n%s\n</style>\n", htmlExportStyle)
b.WriteString("</head>\n<body>\n")

fmt.Fprintf(&b, "<h1>Session: %s</h1>\n", esc(s.Summary))

// Metadata.
b.WriteString("<h2>Metadata</h2>\n<table>\n")
b.WriteString("<tr><th>Field</th><th>Value</th></tr>\n")
fmt.Fprintf(&b, "<tr><td>ID</td><td><code>%s</code></td></tr>\n", esc(s.ID))
fmt.Fprintf(&b, "<tr><td>Folder</td><td><code>%s</code></td></tr>\n", esc(s.Cwd))
if s.Repository != "" {
fmt.Fprintf(&b, "<tr><td>Repository</td><td>%s</td></tr>\n", esc(s.Repository))
}
if s.Branch != "" {
fmt.Fprintf(&b, "<tr><td>Branch</td><td>%s</td></tr>\n", esc(s.Branch))
}
fmt.Fprintf(&b, "<tr><td>Created</td><td>%s</td></tr>\n", esc(s.CreatedAt))
fmt.Fprintf(&b, "<tr><td>Last Active</td><td>%s</td></tr>\n", esc(s.LastActiveAt))
fmt.Fprintf(&b, "<tr><td>Turns</td><td>%d</td></tr>\n", s.TurnCount)
fmt.Fprintf(&b, "<tr><td>Files</td><td>%d</td></tr>\n", s.FileCount)
b.WriteString("</table>\n")

// Conversation.
if len(detail.Turns) > 0 {
b.WriteString("<h2>Conversation</h2>\n")
for _, turn := range detail.Turns {
if turn.UserMessage != "" {
b.WriteString("<div class=\"turn user\">\n<div class=\"role\">User</div>\n")
fmt.Fprintf(&b, "<div class=\"body\">%s</div>\n</div>\n", esc(turn.UserMessage))
}
if turn.AssistantResponse != "" {
b.WriteString("<div class=\"turn assistant\">\n<div class=\"role\">Assistant</div>\n")
fmt.Fprintf(&b, "<div class=\"body\">%s</div>\n</div>\n", esc(turn.AssistantResponse))
}
}
}

// Checkpoints.
if len(detail.Checkpoints) > 0 {
b.WriteString("<h2>Checkpoints</h2>\n")
for _, cp := range detail.Checkpoints {
fmt.Fprintf(&b, "<h3>%d. %s</h3>\n", cp.CheckpointNumber, esc(cp.Title))
if cp.Overview != "" {
fmt.Fprintf(&b, "<p>%s</p>\n", esc(cp.Overview))
}
}
}

// Files.
if len(detail.Files) > 0 {
b.WriteString("<h2>Files Touched</h2>\n<ul>\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, "<li><code>%s</code> (%s)</li>\n", esc(f.FilePath), esc(f.ToolName))
}
b.WriteString("</ul>\n")
}

// References.
if len(detail.Refs) > 0 {
b.WriteString("<h2>References</h2>\n<ul>\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, "<li>%s: %s</li>\n", esc(ref.RefType), esc(ref.RefValue))
}
b.WriteString("</ul>\n")
}

b.WriteString("</body>\n</html>\n")
return b.String()
}

// ExportSession writes a session detail as Markdown to the given directory.
// The file is named <session-id>.md using a sanitized filename. Returns the
// full path of the written file.
Expand Down
115 changes: 115 additions & 0 deletions internal/data/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
"<!DOCTYPE html>",
"<style>",
"<title>Session: Implement feature X</title>",
"<h1>Session: Implement feature X</h1>",
"<code>test-id-123</code>",
"owner/repo",
"Please add tests",
"I&#39;ll add the tests now.",
"1. Initial setup",
"Created project scaffold.",
"</html>",
} {
if !strings.Contains(out, want) {
t.Errorf("RenderHTML output missing %q", want)
}
}

if strings.Count(out, "src/main.go") != 1 {
t.Error("file deduplication failed: src/main.go appears more than once")
}
if strings.Count(out, "abc123") != 1 {
t.Error("ref deduplication failed: abc123 appears more than once")
}
}

func TestRenderHTML_EscapesContent(t *testing.T) {
detail := &SessionDetail{
Session: Session{
ID: "x",
Summary: "Fix <script>alert('x')</script>",
},
Turns: []Turn{
{UserMessage: "look at <b>this</b> & that", AssistantResponse: "done"},
},
}

out := RenderHTML(detail)

if strings.Contains(out, "<script>alert(") {
t.Errorf("RenderHTML must escape raw script tags, got:\n%s", out)
}
if !strings.Contains(out, "&lt;script&gt;") {
t.Error("RenderHTML missing escaped summary")
}
if !strings.Contains(out, "look at &lt;b&gt;this&lt;/b&gt; &amp; that") {
t.Error("RenderHTML missing escaped message body")
}
}

func TestRenderHTML_MinimalDetail(t *testing.T) {
detail := &SessionDetail{
Session: Session{
ID: "minimal-session",
Summary: "Empty session",
},
}

out := RenderHTML(detail)

if !strings.Contains(out, "<h1>Session: Empty session</h1>") {
t.Error("missing title")
}
if strings.Contains(out, "<h2>Conversation</h2>") {
t.Error("conversation section should be absent with no turns")
}
if strings.Contains(out, "<h2>Checkpoints</h2>") {
t.Error("checkpoints section should be absent with no checkpoints")
}
if strings.Contains(out, "<h2>Files Touched</h2>") {
t.Error("files section should be absent with no files")
}
if strings.Contains(out, "<h2>References</h2>") {
t.Error("references section should be absent with no refs")
}
}

func TestRenderMarkdown_MinimalDetail(t *testing.T) {
detail := &SessionDetail{
Session: Session{
Expand Down