Compare commits

..

1 commit

Author SHA1 Message Date
Virgil
36c184e7dd feat(html): add event permalinks
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 1m10s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 04:47:53 +00:00
14 changed files with 225 additions and 517 deletions

View file

@ -31,7 +31,7 @@ type SessionAnalytics struct {
// Example: // Example:
// analytics := session.Analyse(sess) // analytics := session.Analyse(sess)
func Analyse(sess *Session) *SessionAnalytics { func Analyse(sess *Session) *SessionAnalytics {
analytics := &SessionAnalytics{ a := &SessionAnalytics{
ToolCounts: make(map[string]int), ToolCounts: make(map[string]int),
ErrorCounts: make(map[string]int), ErrorCounts: make(map[string]int),
AvgLatency: make(map[string]time.Duration), AvgLatency: make(map[string]time.Duration),
@ -39,11 +39,11 @@ func Analyse(sess *Session) *SessionAnalytics {
} }
if sess == nil { if sess == nil {
return analytics return a
} }
analytics.Duration = sess.EndTime.Sub(sess.StartTime) a.Duration = sess.EndTime.Sub(sess.StartTime)
analytics.EventCount = len(sess.Events) a.EventCount = len(sess.Events)
// Track totals for latency averaging // Track totals for latency averaging
type latencyAccum struct { type latencyAccum struct {
@ -57,23 +57,23 @@ func Analyse(sess *Session) *SessionAnalytics {
for evt := range sess.EventsSeq() { for evt := range sess.EventsSeq() {
// Token estimation: ~4 chars per token // Token estimation: ~4 chars per token
analytics.EstimatedInputTokens += len(evt.Input) / 4 a.EstimatedInputTokens += len(evt.Input) / 4
analytics.EstimatedOutputTokens += len(evt.Output) / 4 a.EstimatedOutputTokens += len(evt.Output) / 4
if evt.Type != "tool_use" { if evt.Type != "tool_use" {
continue continue
} }
totalToolCalls++ totalToolCalls++
analytics.ToolCounts[evt.Tool]++ a.ToolCounts[evt.Tool]++
if !evt.Success { if !evt.Success {
totalErrors++ totalErrors++
analytics.ErrorCounts[evt.Tool]++ a.ErrorCounts[evt.Tool]++
} }
// Active time: sum of tool call durations // Active time: sum of tool call durations
analytics.ActiveTime += evt.Duration a.ActiveTime += evt.Duration
// Latency tracking // Latency tracking
if _, ok := latencies[evt.Tool]; !ok { if _, ok := latencies[evt.Tool]; !ok {
@ -82,24 +82,24 @@ func Analyse(sess *Session) *SessionAnalytics {
latencies[evt.Tool].total += evt.Duration latencies[evt.Tool].total += evt.Duration
latencies[evt.Tool].count++ latencies[evt.Tool].count++
if evt.Duration > analytics.MaxLatency[evt.Tool] { if evt.Duration > a.MaxLatency[evt.Tool] {
analytics.MaxLatency[evt.Tool] = evt.Duration a.MaxLatency[evt.Tool] = evt.Duration
} }
} }
// Compute averages // Compute averages
for tool, accumulator := range latencies { for tool, acc := range latencies {
if accumulator.count > 0 { if acc.count > 0 {
analytics.AvgLatency[tool] = accumulator.total / time.Duration(accumulator.count) a.AvgLatency[tool] = acc.total / time.Duration(acc.count)
} }
} }
// Success rate // Success rate
if totalToolCalls > 0 { if totalToolCalls > 0 {
analytics.SuccessRate = float64(totalToolCalls-totalErrors) / float64(totalToolCalls) a.SuccessRate = float64(totalToolCalls-totalErrors) / float64(totalToolCalls)
} }
return analytics return a
} }
// FormatAnalytics returns a tabular text summary suitable for CLI display. // FormatAnalytics returns a tabular text summary suitable for CLI display.
@ -107,35 +107,35 @@ func Analyse(sess *Session) *SessionAnalytics {
// Example: // Example:
// summary := session.FormatAnalytics(analytics) // summary := session.FormatAnalytics(analytics)
func FormatAnalytics(a *SessionAnalytics) string { func FormatAnalytics(a *SessionAnalytics) string {
builder := core.NewBuilder() b := core.NewBuilder()
builder.WriteString("Session Analytics\n") b.WriteString("Session Analytics\n")
builder.WriteString(repeatString("=", 50) + "\n\n") b.WriteString(repeatString("=", 50) + "\n\n")
builder.WriteString(core.Sprintf(" Duration: %s\n", formatDuration(a.Duration))) b.WriteString(core.Sprintf(" Duration: %s\n", formatDuration(a.Duration)))
builder.WriteString(core.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime))) b.WriteString(core.Sprintf(" Active Time: %s\n", formatDuration(a.ActiveTime)))
builder.WriteString(core.Sprintf(" Events: %d\n", a.EventCount)) b.WriteString(core.Sprintf(" Events: %d\n", a.EventCount))
builder.WriteString(core.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100)) b.WriteString(core.Sprintf(" Success Rate: %.1f%%\n", a.SuccessRate*100))
builder.WriteString(core.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens)) b.WriteString(core.Sprintf(" Est. Input Tk: %d\n", a.EstimatedInputTokens))
builder.WriteString(core.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens)) b.WriteString(core.Sprintf(" Est. Output Tk: %d\n", a.EstimatedOutputTokens))
if len(a.ToolCounts) > 0 { if len(a.ToolCounts) > 0 {
builder.WriteString("\n Tool Breakdown\n") b.WriteString("\n Tool Breakdown\n")
builder.WriteString(" " + repeatString("-", 48) + "\n") b.WriteString(" " + repeatString("-", 48) + "\n")
builder.WriteString(core.Sprintf(" %-14s %6s %6s %10s %10s\n", b.WriteString(core.Sprintf(" %-14s %6s %6s %10s %10s\n",
"Tool", "Calls", "Errors", "Avg", "Max")) "Tool", "Calls", "Errors", "Avg", "Max"))
builder.WriteString(" " + repeatString("-", 48) + "\n") b.WriteString(" " + repeatString("-", 48) + "\n")
// Sort tools for deterministic output // Sort tools for deterministic output
for _, tool := range slices.Sorted(maps.Keys(a.ToolCounts)) { for _, tool := range slices.Sorted(maps.Keys(a.ToolCounts)) {
errors := a.ErrorCounts[tool] errors := a.ErrorCounts[tool]
avg := a.AvgLatency[tool] avg := a.AvgLatency[tool]
max := a.MaxLatency[tool] max := a.MaxLatency[tool]
builder.WriteString(core.Sprintf(" %-14s %6d %6d %10s %10s\n", b.WriteString(core.Sprintf(" %-14s %6d %6d %10s %10s\n",
tool, a.ToolCounts[tool], errors, tool, a.ToolCounts[tool], errors,
formatDuration(avg), formatDuration(max))) formatDuration(avg), formatDuration(max)))
} }
} }
return builder.String() return b.String()
} }

View file

@ -283,101 +283,3 @@ func TestAnalytics_FormatAnalyticsEmptyAnalytics_Good(t *testing.T) {
// No tool breakdown section when no tools // No tool breakdown section when no tools
assert.NotContains(t, output, "Tool Breakdown") assert.NotContains(t, output, "Tool Breakdown")
} }
func TestAnalytics_AnalyseAllToolsFailed_Bad(t *testing.T) {
sess := &Session{
ID: "all-failed",
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 2, 20, 10, 0, 5, 0, time.UTC),
Events: []Event{
{
Type: "tool_use",
Tool: "Bash",
Input: "false",
Output: "exit code 1",
Duration: 100 * time.Millisecond,
Success: false,
ErrorMsg: "exit code 1",
},
{
Type: "tool_use",
Tool: "Read",
Input: "/nonexistent",
Output: "file not found",
Duration: 50 * time.Millisecond,
Success: false,
ErrorMsg: "file not found",
},
},
}
a := Analyse(sess)
assert.Equal(t, 0.0, a.SuccessRate, "all failures should give 0.0 success rate")
assert.Equal(t, 1, a.ErrorCounts["Bash"])
assert.Equal(t, 1, a.ErrorCounts["Read"])
assert.Equal(t, 1, a.ToolCounts["Bash"])
assert.Equal(t, 1, a.ToolCounts["Read"])
}
func TestAnalytics_AnalyseSessionOnlyNonToolEvents_Bad(t *testing.T) {
// Session with only user/assistant events — no tool_use — should produce zero tool stats
sess := &Session{
ID: "no-tools",
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 2, 20, 10, 0, 10, 0, time.UTC),
Events: []Event{
{Type: "user", Input: "Hello"},
{Type: "assistant", Input: "Hi"},
{Type: "user", Input: "Thanks"},
},
}
a := Analyse(sess)
assert.Equal(t, 3, a.EventCount)
assert.Equal(t, 0.0, a.SuccessRate)
assert.Empty(t, a.ToolCounts)
assert.Empty(t, a.ErrorCounts)
assert.Equal(t, time.Duration(0), a.ActiveTime)
}
func TestAnalytics_FormatAnalyticsNilMaps_Ugly(t *testing.T) {
// FormatAnalytics with nil maps — should not panic
a := &SessionAnalytics{}
var output string
assert.NotPanics(t, func() {
output = FormatAnalytics(a)
})
assert.Contains(t, output, "Session Analytics")
}
func TestAnalytics_AnalyseVeryLargeEventCount_Ugly(t *testing.T) {
// Stress: session with 10 000 tool events — should not panic or overflow
events := make([]Event, 10_000)
for i := range events {
events[i] = Event{
Type: "tool_use",
Tool: "Bash",
Input: repeatString("x", 100),
Output: repeatString("y", 200),
Duration: time.Millisecond,
Success: i%2 == 0,
}
}
sess := &Session{
ID: "huge",
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 2, 20, 11, 0, 0, 0, time.UTC),
Events: events,
}
var a *SessionAnalytics
assert.NotPanics(t, func() {
a = Analyse(sess)
})
assert.Equal(t, 10_000, a.EventCount)
assert.Equal(t, 10_000, a.ToolCounts["Bash"])
assert.InDelta(t, 0.5, a.SuccessRate, 0.001)
}

View file

@ -92,12 +92,12 @@ func BenchmarkSearch(b *testing.B) {
func generateBenchJSONL(b testing.TB, dir string, numTools int) string { func generateBenchJSONL(b testing.TB, dir string, numTools int) string {
b.Helper() b.Helper()
builder := core.NewBuilder() sb := core.NewBuilder()
baseTS := "2026-02-20T10:00:00Z" baseTS := "2026-02-20T10:00:00Z"
// Opening user message // Opening user message
builder.WriteString(core.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS)) sb.WriteString(core.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS))
builder.WriteByte('\n') sb.WriteByte('\n')
for i := range numTools { for i := range numTools {
toolID := core.Sprintf("tool-%d", i) toolID := core.Sprintf("tool-%d", i)
@ -133,18 +133,18 @@ func generateBenchJSONL(b testing.TB, dir string, numTools int) string {
(offset+1)/60, (offset+1)%60, toolID) (offset+1)/60, (offset+1)%60, toolID)
} }
builder.WriteString(toolUse) sb.WriteString(toolUse)
builder.WriteByte('\n') sb.WriteByte('\n')
builder.WriteString(toolResult) sb.WriteString(toolResult)
builder.WriteByte('\n') sb.WriteByte('\n')
} }
// Closing assistant message // Closing assistant message
builder.WriteString(core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n")) sb.WriteString(core.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n"))
name := core.Sprintf("bench-%d.jsonl", numTools) name := core.Sprintf("bench-%d.jsonl", numTools)
filePath := path.Join(dir, name) filePath := path.Join(dir, name)
writeResult := hostFS.Write(filePath, builder.String()) writeResult := hostFS.Write(filePath, sb.String())
if !writeResult.OK { if !writeResult.OK {
b.Fatal(resultError(writeResult)) b.Fatal(resultError(writeResult))
} }

View file

@ -195,73 +195,6 @@ func parseGoFiles(t *testing.T, dir string) []parsedFile {
return files return files
} }
func TestConventions_BannedImportPresent_Bad(t *testing.T) {
// Verify the banned-import check fires when a banned import is used.
// We parse a synthetic file that imports "fmt" and confirm the list is non-empty.
dir := t.TempDir()
writeTestFile(t, path.Join(dir, "bad.go"), "package session\nimport \"fmt\"\nvar _ = fmt.Sprintf\n")
files := parseGoFiles(t, dir)
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
banned := map[string]bool{"fmt": true}
var found []string
for _, spec := range files[0].ast.Imports {
importPath := trimQuotes(spec.Path.Value)
if banned[importPath] {
found = append(found, importPath)
}
}
if len(found) == 0 {
t.Fatal("expected to detect banned import 'fmt' in synthetic file, got none")
}
}
func TestConventions_TestNamingInvalidPattern_Bad(t *testing.T) {
// A function that begins with Test but lacks the Good/Bad/Ugly suffix should not
// match testNamePattern.
badNames := []string{
"TestFoo",
"TestFoo_Bar",
"TestFoo_Bar_",
"TestFoo_Bar_Maybe",
}
for _, name := range badNames {
if testNamePattern.MatchString(name) {
t.Errorf("name %q should NOT match testNamePattern", name)
}
}
goodNames := []string{
"TestFoo_Bar_Good",
"TestFoo_Bar_Bad",
"TestFoo_Bar_Ugly",
}
for _, name := range goodNames {
if !testNamePattern.MatchString(name) {
t.Errorf("name %q should match testNamePattern", name)
}
}
}
func TestConventions_ParseGoFilesEmptyDir_Ugly(t *testing.T) {
// parseGoFiles with no Go files in dir should fatalf — we test via a dir
// that only has a non-Go file.
dir := t.TempDir()
writeTestFile(t, path.Join(dir, "readme.md"), "# hello\n")
// We cannot call t.Fatal from within the deferred function, so we capture the
// outcome by running parseGoFiles through a stub that overrides Fatal.
// Instead, verify that PathGlob returns empty for that dir.
matches := core.PathGlob(path.Join(dir, "*.go"))
if len(matches) != 0 {
t.Fatalf("expected no .go files in temp dir, got %d", len(matches))
}
}
func TestConventions_ParseGoFilesMultiplePackages_Good(t *testing.T) { func TestConventions_ParseGoFilesMultiplePackages_Good(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()

View file

@ -26,9 +26,6 @@ func (m rawJSON) MarshalJSON() ([]byte, error) {
return m, nil return m, nil
} }
// resultError extracts the error from a core.Result, or constructs one if absent.
//
// err := resultError(hostFS.Write(path, content))
func resultError(result core.Result) error { func resultError(result core.Result) error {
if result.OK { if result.OK {
return nil return nil
@ -39,44 +36,32 @@ func resultError(result core.Result) error {
return core.E("resultError", "unexpected core result failure", nil) return core.E("resultError", "unexpected core result failure", nil)
} }
// repeatString returns text repeated count times. func repeatString(s string, count int) string {
// if s == "" || count <= 0 {
// repeatString("=", 50) // "=================================================="
func repeatString(text string, count int) string {
if text == "" || count <= 0 {
return "" return ""
} }
return string(bytes.Repeat([]byte(text), count)) return string(bytes.Repeat([]byte(s), count))
} }
// containsAny reports whether text contains any rune in chars. func containsAny(s, chars string) bool {
// for _, ch := range chars {
// containsAny("/tmp/file", `/\`) // true if bytes.IndexRune([]byte(s), ch) >= 0 {
func containsAny(text, chars string) bool {
for _, character := range chars {
if bytes.IndexRune([]byte(text), character) >= 0 {
return true return true
} }
} }
return false return false
} }
// indexOf returns the byte offset of substr in text, or -1 if not found. func indexOf(s, substr string) int {
// return bytes.Index([]byte(s), []byte(substr))
// indexOf("hello world", "world") // 6
func indexOf(text, substr string) int {
return bytes.Index([]byte(text), []byte(substr))
} }
// trimQuotes strips a single layer of matching double-quotes or back-ticks. func trimQuotes(s string) string {
// if len(s) < 2 {
// trimQuotes(`"hello"`) // "hello" return s
func trimQuotes(text string) string {
if len(text) < 2 {
return text
} }
if (text[0] == '"' && text[len(text)-1] == '"') || (text[0] == '`' && text[len(text)-1] == '`') { if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '`' && s[len(s)-1] == '`') {
return text[1 : len(text)-1] return s[1 : len(s)-1]
} }
return text return s
} }

View file

@ -239,10 +239,11 @@ Success or failure of a `tool_use` event is indicated by a Unicode check mark (U
Each event is rendered as a `<div class="event">` containing: Each event is rendered as a `<div class="event">` containing:
- `.event-header`: always visible; shows timestamp, tool label, truncated input (120 chars), duration, and status icon. - `.event-header`: always visible; shows timestamp, tool label, truncated input (120 chars), duration, status icon, and a permalink anchor.
- `.event-body`: hidden by default; shown on click via the `toggle(i)` JavaScript function which toggles the `open` class. - `.event-body`: hidden by default; shown on click via the `toggle(i)` JavaScript function which toggles the `open` class.
The arrow indicator rotates 90 degrees (CSS `transform: rotate(90deg)`) when the panel is open. Output text in `.event-body` is capped at 400px height with `overflow-y: auto`. The arrow indicator rotates 90 degrees (CSS `transform: rotate(90deg)`) when the panel is open. Output text in `.event-body` is capped at 400px height with `overflow-y: auto`.
If the page loads with an `#evt-N` fragment, that event is opened automatically and scrolled into view.
Input label semantics vary per tool: Input label semantics vary per tool:

View file

@ -76,5 +76,5 @@ The following have been identified as potential improvements but are not current
- **Parallel search**: fan out `ParseTranscript` calls across goroutines with a result channel to reduce wall time for large directories. - **Parallel search**: fan out `ParseTranscript` calls across goroutines with a result channel to reduce wall time for large directories.
- **Persistent index**: a lightweight SQLite index or binary cache per session file to avoid re-parsing on every `Search` or `ListSessions` call. - **Persistent index**: a lightweight SQLite index or binary cache per session file to avoid re-parsing on every `Search` or `ListSessions` call.
- **Additional tool types**: the parser's `extractToolInput` fallback handles any unknown tool by listing its JSON keys. Dedicated handling could be added for `WebFetch`, `WebSearch`, `NotebookEdit`, and other tools that appear in Claude Code sessions. - **Additional tool types**: the parser's `extractToolInput` fallback handles any unknown tool by listing its JSON keys. Dedicated handling could be added for `WebFetch`, `WebSearch`, `NotebookEdit`, and other tools that appear in Claude Code sessions.
- **HTML export options**: configurable truncation limits, optional full-output display, and per-event direct links (anchor IDs already exist as `evt-{i}`). - **HTML export options**: configurable truncation limits and optional full-output display remain open; per-event direct links are now available via `#evt-{i}` permalinks.
- **VHS alternative**: a pure-Go terminal animation renderer to eliminate the `vhs` dependency for MP4 output. - **VHS alternative**: a pure-Go terminal animation renderer to eliminate the `vhs` dependency for MP4 output.

68
html.go
View file

@ -21,17 +21,17 @@ func RenderHTML(sess *Session, outputPath string) error {
duration := sess.EndTime.Sub(sess.StartTime) duration := sess.EndTime.Sub(sess.StartTime)
toolCount := 0 toolCount := 0
errorCount := 0 errorCount := 0
for evt := range sess.EventsSeq() { for e := range sess.EventsSeq() {
if evt.Type == "tool_use" { if e.Type == "tool_use" {
toolCount++ toolCount++
if !evt.Success { if !e.Success {
errorCount++ errorCount++
} }
} }
} }
builder := core.NewBuilder() b := core.NewBuilder()
builder.WriteString(core.Sprintf(`<!DOCTYPE html> b.WriteString(core.Sprintf(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@ -71,6 +71,8 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
.event-header .input { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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 .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 .status { font-size: 14px; min-width: 20px; text-align: center; }
.event-header .permalink { color: var(--dim); font-size: 12px; min-width: 16px; text-align: center; text-decoration: none; }
.event-header .permalink:hover { color: var(--accent); }
.event-header .arrow { color: var(--dim); font-size: 10px; transition: transform 0.15s; min-width: 16px; } .event-header .arrow { color: var(--dim); font-size: 10px; transition: transform 0.15s; min-width: 16px; }
.event.open .arrow { transform: rotate(90deg); } .event.open .arrow { transform: rotate(90deg); }
.event-body { display: none; padding: 12px; background: var(--bg); border-top: 1px solid var(--border); } .event-body { display: none; padding: 12px; background: var(--bg); border-top: 1px solid var(--border); }
@ -96,11 +98,11 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
toolCount)) toolCount))
if errorCount > 0 { if errorCount > 0 {
builder.WriteString(core.Sprintf(` b.WriteString(core.Sprintf(`
<span class="err">%d errors</span>`, errorCount)) <span class="err">%d errors</span>`, errorCount))
} }
builder.WriteString(` b.WriteString(`
</div> </div>
</div> </div>
<div class="search"> <div class="search">
@ -117,7 +119,7 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
<div class="timeline" id="timeline"> <div class="timeline" id="timeline">
`) `)
var eventIndex int var i int
for evt := range sess.EventsSeq() { for evt := range sess.EventsSeq() {
toolClass := core.Lower(evt.Tool) toolClass := core.Lower(evt.Tool)
if evt.Type == "user" { if evt.Type == "user" {
@ -147,12 +149,12 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
toolLabel = "Claude" toolLabel = "Claude"
} }
durationString := "" durStr := ""
if evt.Duration > 0 { if evt.Duration > 0 {
durationString = formatDuration(evt.Duration) durStr = formatDuration(evt.Duration)
} }
builder.WriteString(core.Sprintf(`<div class="event%s" data-type="%s" data-tool="%s" data-text="%s" id="evt-%d"> b.WriteString(core.Sprintf(`<div class="event%s" data-type="%s" data-tool="%s" data-text="%s" id="evt-%d">
<div class="event-header" onclick="toggle(%d)"> <div class="event-header" onclick="toggle(%d)">
<span class="arrow">&#9654;</span> <span class="arrow">&#9654;</span>
<span class="time">%s</span> <span class="time">%s</span>
@ -160,6 +162,7 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
<span class="input">%s</span> <span class="input">%s</span>
<span class="dur">%s</span> <span class="dur">%s</span>
<span class="status">%s</span> <span class="status">%s</span>
<a class="permalink" href="#evt-%d" aria-label="Direct link to this event" onclick="event.stopPropagation()">#</a>
</div> </div>
<div class="event-body"> <div class="event-body">
`, `,
@ -167,14 +170,15 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
evt.Type, evt.Type,
evt.Tool, evt.Tool,
html.EscapeString(core.Lower(core.Concat(evt.Input, " ", evt.Output))), html.EscapeString(core.Lower(core.Concat(evt.Input, " ", evt.Output))),
eventIndex, i,
eventIndex, i,
evt.Timestamp.Format("15:04:05"), evt.Timestamp.Format("15:04:05"),
toolClass, toolClass,
html.EscapeString(toolLabel), html.EscapeString(toolLabel),
html.EscapeString(truncate(evt.Input, 120)), html.EscapeString(truncate(evt.Input, 120)),
durationString, durStr,
statusIcon)) statusIcon,
i))
if evt.Input != "" { if evt.Input != "" {
label := "Command" label := "Command"
@ -187,26 +191,26 @@ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-s
} else if evt.Tool == "Edit" || evt.Tool == "Write" { } else if evt.Tool == "Edit" || evt.Tool == "Write" {
label = "File" label = "File"
} }
builder.WriteString(core.Sprintf(` <div class="section"><div class="label">%s</div><pre>%s</pre></div> b.WriteString(core.Sprintf(` <div class="section"><div class="label">%s</div><pre>%s</pre></div>
`, label, html.EscapeString(evt.Input))) `, label, html.EscapeString(evt.Input)))
} }
if evt.Output != "" { if evt.Output != "" {
outputClass := "output" outClass := "output"
if !evt.Success { if !evt.Success {
outputClass = "output err" outClass = "output err"
} }
builder.WriteString(core.Sprintf(` <div class="section"><div class="label">Output</div><pre class="%s">%s</pre></div> b.WriteString(core.Sprintf(` <div class="section"><div class="label">Output</div><pre class="%s">%s</pre></div>
`, outputClass, html.EscapeString(evt.Output))) `, outClass, html.EscapeString(evt.Output)))
} }
builder.WriteString(` </div> b.WriteString(` </div>
</div> </div>
`) `)
eventIndex++ i++
} }
builder.WriteString(`</div> b.WriteString(`</div>
<script> <script>
function toggle(i) { function toggle(i) {
document.getElementById('evt-'+i).classList.toggle('open'); document.getElementById('evt-'+i).classList.toggle('open');
@ -227,18 +231,28 @@ function filterEvents() {
el.classList.toggle('hidden', !show); el.classList.toggle('hidden', !show);
}); });
} }
function openHashEvent() {
const hash = window.location.hash;
if (!hash || !hash.startsWith('#evt-')) return;
const el = document.getElementById(hash.slice(1));
if (!el) return;
el.classList.add('open');
el.scrollIntoView({block: 'start'});
}
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') { if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault(); e.preventDefault();
document.getElementById('search').focus(); document.getElementById('search').focus();
} }
}); });
window.addEventListener('hashchange', openHashEvent);
document.addEventListener('DOMContentLoaded', openHashEvent);
</script> </script>
</body> </body>
</html> </html>
`) `)
writeResult := hostFS.Write(outputPath, builder.String()) writeResult := hostFS.Write(outputPath, b.String())
if !writeResult.OK { if !writeResult.OK {
return core.E("RenderHTML", "write html", resultError(writeResult)) return core.E("RenderHTML", "write html", resultError(writeResult))
} }
@ -246,9 +260,6 @@ document.addEventListener('keydown', e => {
return nil return nil
} }
// shortID returns the first 8 characters of an ID for display purposes.
//
// shortID("abc123def456") // "abc123de"
func shortID(id string) string { func shortID(id string) string {
if len(id) > 8 { if len(id) > 8 {
return id[:8] return id[:8]
@ -256,9 +267,6 @@ func shortID(id string) string {
return id return id
} }
// formatDuration renders a duration as a compact human-readable string.
//
// formatDuration(5*time.Minute + 30*time.Second) // "5m30s"
func formatDuration(d time.Duration) string { func formatDuration(d time.Duration) string {
if d < time.Second { if d < time.Second {
return core.Sprintf("%dms", d.Milliseconds()) return core.Sprintf("%dms", d.Milliseconds())

View file

@ -73,6 +73,8 @@ func TestHTML_RenderHTMLBasicSession_Good(t *testing.T) {
assert.Contains(t, html, "Claude") // assistant event label assert.Contains(t, html, "Claude") // assistant event label
assert.Contains(t, html, "Bash") assert.Contains(t, html, "Bash")
assert.Contains(t, html, "Read") assert.Contains(t, html, "Read")
assert.Contains(t, html, `href="#evt-0"`)
assert.Contains(t, html, "openHashEvent")
// Should contain JS for toggle and filter // Should contain JS for toggle and filter
assert.Contains(t, html, "function toggle") assert.Contains(t, html, "function toggle")
assert.Contains(t, html, "function filterEvents") assert.Contains(t, html, "function filterEvents")
@ -230,68 +232,3 @@ func TestHTML_RenderHTMLLabelsByToolType_Good(t *testing.T) {
// Edit, Write get "File" label // Edit, Write get "File" label
assert.True(t, core.Contains(html, "File"), "Edit/Write events should use 'File' label") assert.True(t, core.Contains(html, "File"), "Edit/Write events should use 'File' label")
} }
func TestHTML_RenderHTMLSessionWithNoEvents_Bad(t *testing.T) {
// A session that has zero events should still produce valid HTML with 0 tool calls
dir := t.TempDir()
outputPath := dir + "/no-events.html"
sess := &Session{
ID: "no-events",
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
Events: []Event{},
}
err := RenderHTML(sess, outputPath)
require.NoError(t, err)
readResult := hostFS.Read(outputPath)
require.True(t, readResult.OK)
htmlContent := readResult.Value.(string)
assert.Contains(t, htmlContent, "0 tool calls")
assert.Contains(t, htmlContent, "<!DOCTYPE html>")
}
func TestHTML_RenderHTMLAllEventsAreErrors_Bad(t *testing.T) {
// All tool events failed — error count should match tool count
dir := t.TempDir()
outputPath := dir + "/all-errors.html"
sess := &Session{
ID: "all-errors",
StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 2, 20, 10, 0, 5, 0, time.UTC),
Events: []Event{
{
Timestamp: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC),
Type: "tool_use",
Tool: "Bash",
Input: "false",
Output: "exit 1",
Duration: 50 * time.Millisecond,
Success: false,
ErrorMsg: "exit 1",
},
{
Timestamp: time.Date(2026, 2, 20, 10, 0, 2, 0, time.UTC),
Type: "tool_use",
Tool: "Bash",
Input: "cat /missing",
Output: "No such file",
Duration: 30 * time.Millisecond,
Success: false,
ErrorMsg: "No such file",
},
},
}
err := RenderHTML(sess, outputPath)
require.NoError(t, err)
readResult := hostFS.Read(outputPath)
require.True(t, readResult.OK)
htmlContent := readResult.Value.(string)
assert.Contains(t, htmlContent, "2 errors")
assert.Contains(t, htmlContent, `class="event error"`)
}

View file

@ -15,6 +15,7 @@ go-session provides two output formats for visualising parsed sessions: a self-c
- Yellow: User messages - Yellow: User messages
- Grey: Assistant responses - Grey: Assistant responses
- Red border: Failed tool calls - Red border: Failed tool calls
- **Permalinks** on each event card for direct `#evt-N` links
### Usage ### Usage

173
parser.go
View file

@ -165,7 +165,7 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
continue continue
} }
sess := Session{ s := Session{
ID: id, ID: id,
Path: filePath, Path: filePath,
} }
@ -175,14 +175,14 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
if !openResult.OK { if !openResult.OK {
continue continue
} }
fileHandle, ok := openResult.Value.(io.ReadCloser) f, ok := openResult.Value.(io.ReadCloser)
if !ok { if !ok {
continue continue
} }
scanner := bufio.NewScanner(fileHandle) scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
var firstTimestamp, lastTimestamp string var firstTS, lastTS string
for scanner.Scan() { for scanner.Scan() {
var entry rawEntry var entry rawEntry
if !core.JSONUnmarshal(scanner.Bytes(), &entry).OK { if !core.JSONUnmarshal(scanner.Bytes(), &entry).OK {
@ -191,36 +191,36 @@ func ListSessionsSeq(projectsDir string) iter.Seq[Session] {
if entry.Timestamp == "" { if entry.Timestamp == "" {
continue continue
} }
if firstTimestamp == "" { if firstTS == "" {
firstTimestamp = entry.Timestamp firstTS = entry.Timestamp
} }
lastTimestamp = entry.Timestamp lastTS = entry.Timestamp
} }
fileHandle.Close() f.Close()
if firstTimestamp != "" { if firstTS != "" {
if parsedTime, err := time.Parse(time.RFC3339Nano, firstTimestamp); err == nil { if t, err := time.Parse(time.RFC3339Nano, firstTS); err == nil {
sess.StartTime = parsedTime s.StartTime = t
} }
} }
if lastTimestamp != "" { if lastTS != "" {
if parsedTime, err := time.Parse(time.RFC3339Nano, lastTimestamp); err == nil { if t, err := time.Parse(time.RFC3339Nano, lastTS); err == nil {
sess.EndTime = parsedTime s.EndTime = t
} }
} }
if sess.StartTime.IsZero() { if s.StartTime.IsZero() {
sess.StartTime = info.ModTime() s.StartTime = info.ModTime()
} }
sessions = append(sessions, sess) sessions = append(sessions, s)
} }
slices.SortFunc(sessions, func(i, j Session) int { slices.SortFunc(sessions, func(i, j Session) int {
return j.StartTime.Compare(i.StartTime) return j.StartTime.Compare(i.StartTime)
}) })
for _, sess := range sessions { for _, s := range sessions {
if !yield(sess) { if !yield(s) {
return return
} }
} }
@ -292,16 +292,16 @@ func ParseTranscript(filePath string) (*Session, *ParseStats, error) {
if !openResult.OK { if !openResult.OK {
return nil, nil, core.E("ParseTranscript", "open transcript", resultError(openResult)) return nil, nil, core.E("ParseTranscript", "open transcript", resultError(openResult))
} }
fileHandle, ok := openResult.Value.(io.ReadCloser) f, ok := openResult.Value.(io.ReadCloser)
if !ok { if !ok {
return nil, nil, core.E("ParseTranscript", "unexpected file handle type", nil) return nil, nil, core.E("ParseTranscript", "unexpected file handle type", nil)
} }
defer fileHandle.Close() defer f.Close()
base := path.Base(filePath) base := path.Base(filePath)
id := core.TrimSuffix(base, ".jsonl") id := core.TrimSuffix(base, ".jsonl")
sess, stats, err := parseFromReader(fileHandle, id) sess, stats, err := parseFromReader(f, id)
if sess != nil { if sess != nil {
sess.Path = filePath sess.Path = filePath
} }
@ -325,9 +325,9 @@ func ParseTranscriptReader(r io.Reader, id string) (*Session, *ParseStats, error
return sess, stats, nil return sess, stats, nil
} }
// parseFromReader scans r line-by-line and returns parsed session events. // parseFromReader is the shared implementation for both file-based and
// // reader-based parsing. It scans line-by-line using bufio.Scanner with
// sess, stats, err := parseFromReader(f, "abc123") // an 8 MiB buffer, gracefully skipping malformed lines.
func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) { func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
sess := &Session{ sess := &Session{
ID: id, ID: id,
@ -375,27 +375,27 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
continue continue
} }
entryTime, err := time.Parse(time.RFC3339Nano, entry.Timestamp) ts, err := time.Parse(time.RFC3339Nano, entry.Timestamp)
if err != nil { if err != nil {
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: bad timestamp %q: %v", lineNum, entry.Timestamp, err)) stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: bad timestamp %q: %v", lineNum, entry.Timestamp, err))
continue continue
} }
if sess.StartTime.IsZero() && !entryTime.IsZero() { if sess.StartTime.IsZero() && !ts.IsZero() {
sess.StartTime = entryTime sess.StartTime = ts
} }
if !entryTime.IsZero() { if !ts.IsZero() {
sess.EndTime = entryTime sess.EndTime = ts
} }
switch entry.Type { switch entry.Type {
case "assistant": case "assistant":
var message rawMessage var msg rawMessage
if !core.JSONUnmarshal(entry.Message, &message).OK { if !core.JSONUnmarshal(entry.Message, &msg).OK {
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal assistant message", lineNum)) stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal assistant message", lineNum))
continue continue
} }
for i, raw := range message.Content { for i, raw := range msg.Content {
var block contentBlock var block contentBlock
if !core.JSONUnmarshal(raw, &block).OK { if !core.JSONUnmarshal(raw, &block).OK {
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i)) stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i))
@ -406,29 +406,29 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
case "text": case "text":
if text := core.Trim(block.Text); text != "" { if text := core.Trim(block.Text); text != "" {
sess.Events = append(sess.Events, Event{ sess.Events = append(sess.Events, Event{
Timestamp: entryTime, Timestamp: ts,
Type: "assistant", Type: "assistant",
Input: truncate(text, 500), Input: truncate(text, 500),
}) })
} }
case "tool_use": case "tool_use":
toolInput := extractToolInput(block.Name, block.Input) inputStr := extractToolInput(block.Name, block.Input)
pendingTools[block.ID] = toolUse{ pendingTools[block.ID] = toolUse{
timestamp: entryTime, timestamp: ts,
tool: block.Name, tool: block.Name,
input: toolInput, input: inputStr,
} }
} }
} }
case "user": case "user":
var message rawMessage var msg rawMessage
if !core.JSONUnmarshal(entry.Message, &message).OK { if !core.JSONUnmarshal(entry.Message, &msg).OK {
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal user message", lineNum)) stats.Warnings = append(stats.Warnings, core.Sprintf("line %d: failed to unmarshal user message", lineNum))
continue continue
} }
for i, raw := range message.Content { for i, raw := range msg.Content {
var block contentBlock var block contentBlock
if !core.JSONUnmarshal(raw, &block).OK { if !core.JSONUnmarshal(raw, &block).OK {
stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i)) stats.Warnings = append(stats.Warnings, core.Sprintf("line %d block %d: failed to unmarshal content", lineNum, i))
@ -437,17 +437,17 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
switch block.Type { switch block.Type {
case "tool_result": case "tool_result":
if pendingTool, ok := pendingTools[block.ToolUseID]; ok { if tu, ok := pendingTools[block.ToolUseID]; ok {
output := extractResultContent(block.Content) output := extractResultContent(block.Content)
isError := block.IsError != nil && *block.IsError isError := block.IsError != nil && *block.IsError
evt := Event{ evt := Event{
Timestamp: pendingTool.timestamp, Timestamp: tu.timestamp,
Type: "tool_use", Type: "tool_use",
Tool: pendingTool.tool, Tool: tu.tool,
ToolID: block.ToolUseID, ToolID: block.ToolUseID,
Input: pendingTool.input, Input: tu.input,
Output: truncate(output, 2000), Output: truncate(output, 2000),
Duration: entryTime.Sub(pendingTool.timestamp), Duration: ts.Sub(tu.timestamp),
Success: !isError, Success: !isError,
} }
if isError { if isError {
@ -460,7 +460,7 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
case "text": case "text":
if text := core.Trim(block.Text); text != "" { if text := core.Trim(block.Text); text != "" {
sess.Events = append(sess.Events, Event{ sess.Events = append(sess.Events, Event{
Timestamp: entryTime, Timestamp: ts,
Type: "user", Type: "user",
Input: truncate(text, 500), Input: truncate(text, 500),
}) })
@ -492,9 +492,6 @@ func parseFromReader(r io.Reader, id string) (*Session, *ParseStats, error) {
return sess, stats, nil return sess, stats, nil
} }
// extractToolInput decodes a tool's raw JSON input to a human-readable string.
//
// label := extractToolInput("Bash", raw) // "ls -la # list files"
func extractToolInput(toolName string, raw rawJSON) string { func extractToolInput(toolName string, raw rawJSON) string {
if raw == nil { if raw == nil {
return "" return ""
@ -502,67 +499,64 @@ func extractToolInput(toolName string, raw rawJSON) string {
switch toolName { switch toolName {
case "Bash": case "Bash":
var input bashInput var inp bashInput
if core.JSONUnmarshal(raw, &input).OK { if core.JSONUnmarshal(raw, &inp).OK {
desc := input.Description desc := inp.Description
if desc != "" { if desc != "" {
desc = " # " + desc desc = " # " + desc
} }
return input.Command + desc return inp.Command + desc
} }
case "Read": case "Read":
var input readInput var inp readInput
if core.JSONUnmarshal(raw, &input).OK { if core.JSONUnmarshal(raw, &inp).OK {
return input.FilePath return inp.FilePath
} }
case "Edit": case "Edit":
var input editInput var inp editInput
if core.JSONUnmarshal(raw, &input).OK { if core.JSONUnmarshal(raw, &inp).OK {
return core.Sprintf("%s (edit)", input.FilePath) return core.Sprintf("%s (edit)", inp.FilePath)
} }
case "Write": case "Write":
var input writeInput var inp writeInput
if core.JSONUnmarshal(raw, &input).OK { if core.JSONUnmarshal(raw, &inp).OK {
return core.Sprintf("%s (%d bytes)", input.FilePath, len(input.Content)) return core.Sprintf("%s (%d bytes)", inp.FilePath, len(inp.Content))
} }
case "Grep": case "Grep":
var input grepInput var inp grepInput
if core.JSONUnmarshal(raw, &input).OK { if core.JSONUnmarshal(raw, &inp).OK {
grepPath := input.Path path := inp.Path
if grepPath == "" { if path == "" {
grepPath = "." path = "."
} }
return core.Sprintf("/%s/ in %s", input.Pattern, grepPath) return core.Sprintf("/%s/ in %s", inp.Pattern, path)
} }
case "Glob": case "Glob":
var input globInput var inp globInput
if core.JSONUnmarshal(raw, &input).OK { if core.JSONUnmarshal(raw, &inp).OK {
return input.Pattern return inp.Pattern
} }
case "Task": case "Task":
var input taskInput var inp taskInput
if core.JSONUnmarshal(raw, &input).OK { if core.JSONUnmarshal(raw, &inp).OK {
desc := input.Description desc := inp.Description
if desc == "" { if desc == "" {
desc = truncate(input.Prompt, 80) desc = truncate(inp.Prompt, 80)
} }
return core.Sprintf("[%s] %s", input.SubagentType, desc) return core.Sprintf("[%s] %s", inp.SubagentType, desc)
} }
} }
// Fallback: show raw JSON keys // Fallback: show raw JSON keys
var jsonFields map[string]any var m map[string]any
if core.JSONUnmarshal(raw, &jsonFields).OK { if core.JSONUnmarshal(raw, &m).OK {
parts := slices.Sorted(maps.Keys(jsonFields)) parts := slices.Sorted(maps.Keys(m))
return core.Join(", ", parts...) return core.Join(", ", parts...)
} }
return "" return ""
} }
// extractResultContent coerces a tool_result content value to a plain string.
//
// text := extractResultContent(block.Content) // "total 42\n..."
func extractResultContent(content any) string { func extractResultContent(content any) string {
switch v := content.(type) { switch v := content.(type) {
case string: case string:
@ -570,8 +564,8 @@ func extractResultContent(content any) string {
case []any: case []any:
var parts []string var parts []string
for _, item := range v { for _, item := range v {
if contentMap, ok := item.(map[string]any); ok { if m, ok := item.(map[string]any); ok {
if text, ok := contentMap["text"].(string); ok { if text, ok := m["text"].(string); ok {
parts = append(parts, text) parts = append(parts, text)
} }
} }
@ -585,12 +579,9 @@ func extractResultContent(content any) string {
return core.Sprint(content) return core.Sprint(content)
} }
// truncate clips text to at most maxLen bytes, appending "..." if clipped. func truncate(s string, max int) string {
// if len(s) <= max {
// truncate("hello world", 5) // "hello..." return s
func truncate(text string, maxLen int) string {
if len(text) <= maxLen {
return text
} }
return text[:maxLen] + "..." return s[:max] + "..."
} }

View file

@ -54,17 +54,17 @@ func SearchSeq(projectsDir, query string) iter.Seq[SearchResult] {
} }
text := core.Lower(core.Concat(evt.Input, " ", evt.Output)) text := core.Lower(core.Concat(evt.Input, " ", evt.Output))
if core.Contains(text, query) { if core.Contains(text, query) {
matchContext := evt.Input matchCtx := evt.Input
if matchContext == "" { if matchCtx == "" {
matchContext = truncate(evt.Output, 120) matchCtx = truncate(evt.Output, 120)
} }
result := SearchResult{ res := SearchResult{
SessionID: sess.ID, SessionID: sess.ID,
Timestamp: evt.Timestamp, Timestamp: evt.Timestamp,
Tool: evt.Tool, Tool: evt.Tool,
Match: matchContext, Match: matchCtx,
} }
if !yield(result) { if !yield(res) {
return return
} }
} }

View file

@ -5,7 +5,6 @@ import (
"path" "path"
"testing" "testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -164,38 +163,3 @@ func TestSearch_SearchMalformedSessionSkipped_Bad(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, results, 1, "should still find matches in valid sessions") assert.Len(t, results, 1, "should still find matches in valid sessions")
} }
func TestSearch_SearchEmptyQuery_Ugly(t *testing.T) {
// An empty query string matches every tool event (empty substring is always contained)
dir := t.TempDir()
writeJSONL(t, dir, "session.jsonl",
toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "ls"}),
toolResultEntry(ts(1), "t1", "file.go", false),
toolUseEntry(ts(2), "Read", "t2", map[string]any{"file_path": "/tmp/x.go"}),
toolResultEntry(ts(3), "t2", "package main", false),
)
results, err := Search(dir, "")
require.NoError(t, err)
assert.Len(t, results, 2, "empty query should match all tool events")
}
func TestSearch_SearchSeqEarlyTermination_Ugly(t *testing.T) {
// Caller breaks out of SearchSeq early — should not hang or panic
dir := t.TempDir()
for i := range 5 {
name := core.Sprintf("session-%c.jsonl", rune('a'+i))
writeJSONL(t, dir, name,
toolUseEntry(ts(0), "Bash", "t1", map[string]any{"command": "go test"}),
toolResultEntry(ts(1), "t1", "PASS", false),
)
}
var count int
for range SearchSeq(dir, "go test") {
count++
break // early termination
}
assert.Equal(t, 1, count, "early break from SearchSeq should yield exactly one result")
}

View file

@ -40,31 +40,28 @@ func RenderMP4(sess *Session, outputPath string) error {
return nil return nil
} }
// generateTape produces a VHS .tape script from session events.
//
// tape := generateTape(sess, "/tmp/session.mp4")
func generateTape(sess *Session, outputPath string) string { func generateTape(sess *Session, outputPath string) string {
builder := core.NewBuilder() b := core.NewBuilder()
builder.WriteString(core.Sprintf("Output %s\n", outputPath)) b.WriteString(core.Sprintf("Output %s\n", outputPath))
builder.WriteString("Set FontSize 16\n") b.WriteString("Set FontSize 16\n")
builder.WriteString("Set Width 1400\n") b.WriteString("Set Width 1400\n")
builder.WriteString("Set Height 800\n") b.WriteString("Set Height 800\n")
builder.WriteString("Set TypingSpeed 30ms\n") b.WriteString("Set TypingSpeed 30ms\n")
builder.WriteString("Set Theme \"Catppuccin Mocha\"\n") b.WriteString("Set Theme \"Catppuccin Mocha\"\n")
builder.WriteString("Set Shell bash\n") b.WriteString("Set Shell bash\n")
builder.WriteString("\n") b.WriteString("\n")
// Title frame // Title frame
sessionID := sess.ID id := sess.ID
if len(sessionID) > 8 { if len(id) > 8 {
sessionID = sessionID[:8] id = id[:8]
} }
builder.WriteString(core.Sprintf("Type \"# Session %s | %s\"\n", b.WriteString(core.Sprintf("Type \"# Session %s | %s\"\n",
sessionID, sess.StartTime.Format("2006-01-02 15:04"))) id, sess.StartTime.Format("2006-01-02 15:04")))
builder.WriteString("Enter\n") b.WriteString("Enter\n")
builder.WriteString("Sleep 2s\n") b.WriteString("Sleep 2s\n")
builder.WriteString("\n") b.WriteString("\n")
for _, evt := range sess.Events { for _, evt := range sess.Events {
if evt.Type != "tool_use" { if evt.Type != "tool_use" {
@ -78,8 +75,8 @@ func generateTape(sess *Session, outputPath string) string {
continue continue
} }
// Show the command // Show the command
builder.WriteString(core.Sprintf("Type %q\n", "$ "+cmd)) b.WriteString(core.Sprintf("Type %q\n", "$ "+cmd))
builder.WriteString("Enter\n") b.WriteString("Enter\n")
// Show abbreviated output // Show abbreviated output
output := evt.Output output := evt.Output
@ -91,52 +88,47 @@ func generateTape(sess *Session, outputPath string) string {
if line == "" { if line == "" {
continue continue
} }
builder.WriteString(core.Sprintf("Type %q\n", line)) b.WriteString(core.Sprintf("Type %q\n", line))
builder.WriteString("Enter\n") b.WriteString("Enter\n")
} }
} }
// Status indicator // Status indicator
if !evt.Success { if !evt.Success {
builder.WriteString("Type \"# ✗ FAILED\"\n") b.WriteString("Type \"# ✗ FAILED\"\n")
} else { } else {
builder.WriteString("Type \"# ✓ OK\"\n") b.WriteString("Type \"# ✓ OK\"\n")
} }
builder.WriteString("Enter\n") b.WriteString("Enter\n")
builder.WriteString("Sleep 1s\n") b.WriteString("Sleep 1s\n")
builder.WriteString("\n") b.WriteString("\n")
case "Read", "Edit", "Write": case "Read", "Edit", "Write":
builder.WriteString(core.Sprintf("Type %q\n", b.WriteString(core.Sprintf("Type %q\n",
core.Sprintf("# %s: %s", evt.Tool, truncate(evt.Input, 80)))) core.Sprintf("# %s: %s", evt.Tool, truncate(evt.Input, 80))))
builder.WriteString("Enter\n") b.WriteString("Enter\n")
builder.WriteString("Sleep 500ms\n") b.WriteString("Sleep 500ms\n")
case "Task": case "Task":
builder.WriteString(core.Sprintf("Type %q\n", b.WriteString(core.Sprintf("Type %q\n",
core.Sprintf("# Agent: %s", truncate(evt.Input, 80)))) core.Sprintf("# Agent: %s", truncate(evt.Input, 80))))
builder.WriteString("Enter\n") b.WriteString("Enter\n")
builder.WriteString("Sleep 1s\n") b.WriteString("Sleep 1s\n")
} }
} }
builder.WriteString("Sleep 3s\n") b.WriteString("Sleep 3s\n")
return builder.String() return b.String()
} }
// extractCommand strips the description suffix from a Bash input string.
//
// extractCommand("ls -la # list files") // "ls -la"
func extractCommand(input string) string { func extractCommand(input string) string {
if index := indexOf(input, " # "); index > 0 { // Remove description suffix (after " # ")
return input[:index] if idx := indexOf(input, " # "); idx > 0 {
return input[:idx]
} }
return input return input
} }
// lookupExecutable searches PATH for an executable with the given name.
//
// lookupExecutable("vhs") // "/usr/local/bin/vhs"
func lookupExecutable(name string) string { func lookupExecutable(name string) string {
if name == "" { if name == "" {
return "" return ""
@ -160,9 +152,6 @@ func lookupExecutable(name string) string {
return "" return ""
} }
// isExecutablePath reports whether filePath is a regular executable file.
//
// isExecutablePath("/usr/bin/vhs") // true
func isExecutablePath(filePath string) bool { func isExecutablePath(filePath string) bool {
statResult := hostFS.Stat(filePath) statResult := hostFS.Stat(filePath)
if !statResult.OK { if !statResult.OK {
@ -175,17 +164,14 @@ func isExecutablePath(filePath string) bool {
return info.Mode()&0111 != 0 return info.Mode()&0111 != 0
} }
// runCommand executes command with args, inheriting stdio, and waits for exit.
//
// err := runCommand("/usr/local/bin/vhs", "/tmp/session.tape")
func runCommand(command string, args ...string) error { func runCommand(command string, args ...string) error {
arguments := append([]string{command}, args...) argv := append([]string{command}, args...)
procAttr := &syscall.ProcAttr{ procAttr := &syscall.ProcAttr{
Env: syscall.Environ(), Env: syscall.Environ(),
Files: []uintptr{0, 1, 2}, Files: []uintptr{0, 1, 2},
} }
pid, err := syscall.ForkExec(command, arguments, procAttr) pid, err := syscall.ForkExec(command, argv, procAttr)
if err != nil { if err != nil {
return core.E("runCommand", "fork exec command", err) return core.E("runCommand", "fork exec command", err)
} }