258 lines
8.2 KiB
Go
258 lines
8.2 KiB
Go
|
|
package session
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"html"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// RenderHTML generates a self-contained HTML timeline from a session.
|
||
|
|
func RenderHTML(sess *Session, outputPath string) error {
|
||
|
|
f, err := os.Create(outputPath)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("create html: %w", err)
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
duration := sess.EndTime.Sub(sess.StartTime)
|
||
|
|
toolCount := 0
|
||
|
|
errorCount := 0
|
||
|
|
for _, e := range sess.Events {
|
||
|
|
if e.Type == "tool_use" {
|
||
|
|
toolCount++
|
||
|
|
if !e.Success {
|
||
|
|
errorCount++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(f, `<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="utf-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
|
|
<title>Session %s</title>
|
||
|
|
<style>
|
||
|
|
:root {
|
||
|
|
--bg: #0d1117; --bg2: #161b22; --bg3: #21262d;
|
||
|
|
--fg: #c9d1d9; --dim: #8b949e; --accent: #58a6ff;
|
||
|
|
--green: #3fb950; --red: #f85149; --yellow: #d29922;
|
||
|
|
--border: #30363d; --font: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', monospace;
|
||
|
|
}
|
||
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
|
body { background: var(--bg); color: var(--fg); font-family: var(--font); font-size: 13px; line-height: 1.5; }
|
||
|
|
.header { background: var(--bg2); border-bottom: 1px solid var(--border); padding: 16px 24px; position: sticky; top: 0; z-index: 10; }
|
||
|
|
.header h1 { font-size: 16px; font-weight: 600; color: var(--accent); }
|
||
|
|
.header .meta { color: var(--dim); font-size: 12px; margin-top: 4px; }
|
||
|
|
.header .stats span { display: inline-block; margin-right: 16px; }
|
||
|
|
.header .stats .err { color: var(--red); }
|
||
|
|
.search { margin-top: 8px; display: flex; gap: 8px; }
|
||
|
|
.search input { background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-family: var(--font); font-size: 12px; padding: 6px 12px; width: 300px; outline: none; }
|
||
|
|
.search input:focus { border-color: var(--accent); }
|
||
|
|
.search select { background: var(--bg3); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-family: var(--font); font-size: 12px; padding: 6px 8px; outline: none; }
|
||
|
|
.timeline { padding: 16px 24px; }
|
||
|
|
.event { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; transition: border-color 0.15s; }
|
||
|
|
.event:hover { border-color: var(--accent); }
|
||
|
|
.event.error { border-color: var(--red); }
|
||
|
|
.event.hidden { display: none; }
|
||
|
|
.event-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; user-select: none; background: var(--bg2); }
|
||
|
|
.event-header:hover { background: var(--bg3); }
|
||
|
|
.event-header .time { color: var(--dim); font-size: 11px; min-width: 70px; }
|
||
|
|
.event-header .tool { font-weight: 600; color: var(--accent); min-width: 60px; }
|
||
|
|
.event-header .tool.bash { color: var(--green); }
|
||
|
|
.event-header .tool.error { color: var(--red); }
|
||
|
|
.event-header .tool.user { color: var(--yellow); }
|
||
|
|
.event-header .tool.assistant { color: var(--dim); }
|
||
|
|
.event-header .input { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
|
|
.event-header .dur { color: var(--dim); font-size: 11px; min-width: 50px; text-align: right; }
|
||
|
|
.event-header .status { font-size: 14px; min-width: 20px; text-align: center; }
|
||
|
|
.event-header .arrow { color: var(--dim); font-size: 10px; transition: transform 0.15s; min-width: 16px; }
|
||
|
|
.event.open .arrow { transform: rotate(90deg); }
|
||
|
|
.event-body { display: none; padding: 12px; background: var(--bg); border-top: 1px solid var(--border); }
|
||
|
|
.event.open .event-body { display: block; }
|
||
|
|
.event-body pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; max-height: 400px; overflow-y: auto; }
|
||
|
|
.event-body .label { color: var(--dim); font-size: 11px; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
|
|
.event-body .section { margin-bottom: 12px; }
|
||
|
|
.event-body .output { color: var(--fg); }
|
||
|
|
.event-body .output.err { color: var(--red); }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<div class="header">
|
||
|
|
<h1>Session %s</h1>
|
||
|
|
<div class="meta">
|
||
|
|
<div class="stats">
|
||
|
|
<span>%s</span>
|
||
|
|
<span>Duration: %s</span>
|
||
|
|
<span>%d tool calls</span>`,
|
||
|
|
shortID(sess.ID), shortID(sess.ID),
|
||
|
|
sess.StartTime.Format("2006-01-02 15:04:05"),
|
||
|
|
formatDuration(duration),
|
||
|
|
toolCount)
|
||
|
|
|
||
|
|
if errorCount > 0 {
|
||
|
|
fmt.Fprintf(f, `
|
||
|
|
<span class="err">%d errors</span>`, errorCount)
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(f, `
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="search">
|
||
|
|
<input type="text" id="search" placeholder="Search commands, outputs..." oninput="filterEvents()">
|
||
|
|
<select id="filter" onchange="filterEvents()">
|
||
|
|
<option value="all">All events</option>
|
||
|
|
<option value="tool_use">Tool calls only</option>
|
||
|
|
<option value="errors">Errors only</option>
|
||
|
|
<option value="Bash">Bash only</option>
|
||
|
|
<option value="user">User messages</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="timeline" id="timeline">
|
||
|
|
`)
|
||
|
|
|
||
|
|
for i, evt := range sess.Events {
|
||
|
|
toolClass := strings.ToLower(evt.Tool)
|
||
|
|
if evt.Type == "user" {
|
||
|
|
toolClass = "user"
|
||
|
|
} else if evt.Type == "assistant" {
|
||
|
|
toolClass = "assistant"
|
||
|
|
}
|
||
|
|
|
||
|
|
errorClass := ""
|
||
|
|
if !evt.Success && evt.Type == "tool_use" {
|
||
|
|
errorClass = " error"
|
||
|
|
}
|
||
|
|
|
||
|
|
statusIcon := ""
|
||
|
|
if evt.Type == "tool_use" {
|
||
|
|
if evt.Success {
|
||
|
|
statusIcon = `<span style="color:var(--green)">✓</span>`
|
||
|
|
} else {
|
||
|
|
statusIcon = `<span style="color:var(--red)">✗</span>`
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
toolLabel := evt.Tool
|
||
|
|
if evt.Type == "user" {
|
||
|
|
toolLabel = "User"
|
||
|
|
} else if evt.Type == "assistant" {
|
||
|
|
toolLabel = "Claude"
|
||
|
|
}
|
||
|
|
|
||
|
|
durStr := ""
|
||
|
|
if evt.Duration > 0 {
|
||
|
|
durStr = formatDuration(evt.Duration)
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(f, `<div class="event%s" data-type="%s" data-tool="%s" data-text="%s" id="evt-%d">
|
||
|
|
<div class="event-header" onclick="toggle(%d)">
|
||
|
|
<span class="arrow">▶</span>
|
||
|
|
<span class="time">%s</span>
|
||
|
|
<span class="tool %s">%s</span>
|
||
|
|
<span class="input">%s</span>
|
||
|
|
<span class="dur">%s</span>
|
||
|
|
<span class="status">%s</span>
|
||
|
|
</div>
|
||
|
|
<div class="event-body">
|
||
|
|
`,
|
||
|
|
errorClass,
|
||
|
|
evt.Type,
|
||
|
|
evt.Tool,
|
||
|
|
html.EscapeString(strings.ToLower(evt.Input+" "+evt.Output)),
|
||
|
|
i,
|
||
|
|
i,
|
||
|
|
evt.Timestamp.Format("15:04:05"),
|
||
|
|
toolClass,
|
||
|
|
html.EscapeString(toolLabel),
|
||
|
|
html.EscapeString(truncate(evt.Input, 120)),
|
||
|
|
durStr,
|
||
|
|
statusIcon)
|
||
|
|
|
||
|
|
if evt.Input != "" {
|
||
|
|
label := "Command"
|
||
|
|
if evt.Type == "user" {
|
||
|
|
label = "Message"
|
||
|
|
} else if evt.Type == "assistant" {
|
||
|
|
label = "Response"
|
||
|
|
} else if evt.Tool == "Read" || evt.Tool == "Glob" || evt.Tool == "Grep" {
|
||
|
|
label = "Target"
|
||
|
|
} else if evt.Tool == "Edit" || evt.Tool == "Write" {
|
||
|
|
label = "File"
|
||
|
|
}
|
||
|
|
fmt.Fprintf(f, ` <div class="section"><div class="label">%s</div><pre>%s</pre></div>
|
||
|
|
`, label, html.EscapeString(evt.Input))
|
||
|
|
}
|
||
|
|
|
||
|
|
if evt.Output != "" {
|
||
|
|
outClass := "output"
|
||
|
|
if !evt.Success {
|
||
|
|
outClass = "output err"
|
||
|
|
}
|
||
|
|
fmt.Fprintf(f, ` <div class="section"><div class="label">Output</div><pre class="%s">%s</pre></div>
|
||
|
|
`, outClass, html.EscapeString(evt.Output))
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprint(f, ` </div>
|
||
|
|
</div>
|
||
|
|
`)
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprint(f, `</div>
|
||
|
|
<script>
|
||
|
|
function toggle(i) {
|
||
|
|
document.getElementById('evt-'+i).classList.toggle('open');
|
||
|
|
}
|
||
|
|
function filterEvents() {
|
||
|
|
const q = document.getElementById('search').value.toLowerCase();
|
||
|
|
const f = document.getElementById('filter').value;
|
||
|
|
document.querySelectorAll('.event').forEach(el => {
|
||
|
|
const type = el.dataset.type;
|
||
|
|
const tool = el.dataset.tool;
|
||
|
|
const text = el.dataset.text;
|
||
|
|
let show = true;
|
||
|
|
if (f === 'tool_use' && type !== 'tool_use') show = false;
|
||
|
|
if (f === 'errors' && !el.classList.contains('error')) show = false;
|
||
|
|
if (f === 'Bash' && tool !== 'Bash') show = false;
|
||
|
|
if (f === 'user' && type !== 'user') show = false;
|
||
|
|
if (q && !text.includes(q)) show = false;
|
||
|
|
el.classList.toggle('hidden', !show);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
document.addEventListener('keydown', e => {
|
||
|
|
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
||
|
|
e.preventDefault();
|
||
|
|
document.getElementById('search').focus();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
`)
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func shortID(id string) string {
|
||
|
|
if len(id) > 8 {
|
||
|
|
return id[:8]
|
||
|
|
}
|
||
|
|
return id
|
||
|
|
}
|
||
|
|
|
||
|
|
func formatDuration(d time.Duration) string {
|
||
|
|
if d < time.Second {
|
||
|
|
return fmt.Sprintf("%dms", d.Milliseconds())
|
||
|
|
}
|
||
|
|
if d < time.Minute {
|
||
|
|
return fmt.Sprintf("%.1fs", d.Seconds())
|
||
|
|
}
|
||
|
|
if d < time.Hour {
|
||
|
|
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
|
||
|
|
}
|