From 33cdfaa0aa7ac2b432c3ea04f1afd40cbc28ad56 Mon Sep 17 00:00:00 2001 From: Brian 'bdougie' Douglas Date: Sun, 1 Feb 2026 14:39:21 -0800 Subject: [PATCH 1/2] feat: tapes deck visualization (bubbletea + lipgloss) Rework the deck TUI with Bubble Tea and Lip Gloss, including bar charts, split timeline/details drilldown, and refined navigation/sorting/aggregation behavior. --- .gitignore | 2 + cmd/tapes/deck/deck.go | 184 +++++ cmd/tapes/deck/deck_suite_test.go | 13 + cmd/tapes/deck/tui.go | 1274 +++++++++++++++++++++++++++++ cmd/tapes/deck/tui_test.go | 214 +++++ cmd/tapes/deck/web.go | 76 ++ cmd/tapes/tapes.go | 8 +- go.mod | 20 +- go.sum | 37 + pkg/deck/pricing.go | 96 +++ pkg/deck/query.go | 497 +++++++++++ pkg/deck/types.go | 89 ++ proxy/proxy_test.go | 1 - web/deck/deck.css | 246 ++++++ web/deck/deck.js | 145 ++++ web/deck/embed.go | 6 + web/deck/index.html | 40 + 17 files changed, 2945 insertions(+), 3 deletions(-) create mode 100644 cmd/tapes/deck/deck.go create mode 100644 cmd/tapes/deck/deck_suite_test.go create mode 100644 cmd/tapes/deck/tui.go create mode 100644 cmd/tapes/deck/tui_test.go create mode 100644 cmd/tapes/deck/web.go create mode 100644 pkg/deck/pricing.go create mode 100644 pkg/deck/query.go create mode 100644 pkg/deck/types.go create mode 100644 web/deck/deck.css create mode 100644 web/deck/deck.js create mode 100644 web/deck/embed.go create mode 100644 web/deck/index.html diff --git a/.gitignore b/.gitignore index 86e575d..2b0e6fa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /.direnv/ /build/ +/tmp/ +/tapes .DS_Store # JetBrains IDEs diff --git a/cmd/tapes/deck/deck.go b/cmd/tapes/deck/deck.go new file mode 100644 index 0000000..38195e0 --- /dev/null +++ b/cmd/tapes/deck/deck.go @@ -0,0 +1,184 @@ +// Package deckcmder provides the deck command for session ROI dashboards. +package deckcmder + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/papercomputeco/tapes/pkg/deck" +) + +const deckLongDesc string = `Deck is an ROI dashboard for agent sessions. + +Summarize recent sessions with a TUI and drill down into a single session. + +Examples: + tapes deck + tapes deck --since 24h + tapes deck --from 2026-01-30 --to 2026-01-31 + tapes deck --sort cost --model claude-sonnet-4.5 + tapes deck --session sess_a8f2c1d3 + tapes deck --web + tapes deck --web --port 9999 + tapes deck --pricing ./pricing.json +` + +const deckShortDesc string = "Deck - ROI dashboard for agent sessions" + +type deckCommander struct { + sqlitePath string + pricingPath string + since string + from string + to string + sort string + model string + status string + session string + web bool + port int +} + +func NewDeckCmd() *cobra.Command { + cmder := &deckCommander{} + + cmd := &cobra.Command{ + Use: "deck", + Short: deckShortDesc, + Long: deckLongDesc, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmder.run(cmd.Context()) + }, + } + + cmd.Flags().StringVarP(&cmder.sqlitePath, "sqlite", "s", "", "Path to SQLite database") + cmd.Flags().StringVar(&cmder.pricingPath, "pricing", "", "Path to pricing JSON overrides") + cmd.Flags().StringVar(&cmder.since, "since", "", "Look back duration (e.g. 24h)") + cmd.Flags().StringVar(&cmder.from, "from", "", "Start time (YYYY-MM-DD or RFC3339)") + cmd.Flags().StringVar(&cmder.to, "to", "", "End time (YYYY-MM-DD or RFC3339)") + cmd.Flags().StringVar(&cmder.sort, "sort", "cost", "Sort sessions by cost|time|tokens|duration") + cmd.Flags().StringVar(&cmder.model, "model", "", "Filter by model") + cmd.Flags().StringVar(&cmder.status, "status", "", "Filter by status (completed|failed|abandoned)") + cmd.Flags().StringVar(&cmder.session, "session", "", "Drill into a specific session ID") + cmd.Flags().BoolVar(&cmder.web, "web", false, "Serve the web dashboard locally") + cmd.Flags().IntVar(&cmder.port, "port", 8888, "Web server port") + + return cmd +} + +func (c *deckCommander) run(ctx context.Context) error { + pricing, err := deck.LoadPricing(c.pricingPath) + if err != nil { + return err + } + + sqlitePath, err := resolveSQLitePath(c.sqlitePath) + if err != nil { + return err + } + + query, closeFn, err := deck.NewQuery(sqlitePath, pricing) + if err != nil { + return err + } + defer closeFn() + + filters, err := c.parseFilters() + if err != nil { + return err + } + + if c.web { + return runDeckWeb(ctx, query, filters, c.port) + } + + return runDeckTUI(ctx, query, filters) +} + +func (c *deckCommander) parseFilters() (deck.Filters, error) { + filters := deck.Filters{ + Sort: strings.ToLower(strings.TrimSpace(c.sort)), + Model: strings.TrimSpace(c.model), + Status: strings.TrimSpace(c.status), + Session: strings.TrimSpace(c.session), + } + + if c.since != "" { + duration, err := time.ParseDuration(c.since) + if err != nil { + return filters, fmt.Errorf("invalid since duration: %w", err) + } + filters.Since = duration + } + + if c.from != "" { + parsed, err := parseTime(c.from) + if err != nil { + return filters, fmt.Errorf("invalid from time: %w", err) + } + filters.From = &parsed + } + + if c.to != "" { + parsed, err := parseTime(c.to) + if err != nil { + return filters, fmt.Errorf("invalid to time: %w", err) + } + filters.To = &parsed + } + + return filters, nil +} + +func parseTime(value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, fmt.Errorf("empty time") + } + + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + + if parsed, err := time.Parse("2006-01-02", value); err == nil { + return parsed, nil + } + + return time.Time{}, fmt.Errorf("expected RFC3339 or YYYY-MM-DD") +} + +func resolveSQLitePath(override string) (string, error) { + if override != "" { + return override, nil + } + + candidates := []string{ + "tapes.db", + "tapes.sqlite", + filepath.Join(".tapes", "tapes.db"), + filepath.Join(".tapes", "tapes.sqlite"), + } + + home, err := os.UserHomeDir() + if err == nil { + candidates = append(candidates, + filepath.Join(home, ".tapes", "tapes.db"), + filepath.Join(home, ".tapes", "tapes.sqlite"), + ) + } + + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + + return "", fmt.Errorf("could not find tapes SQLite database; pass --sqlite") +} diff --git a/cmd/tapes/deck/deck_suite_test.go b/cmd/tapes/deck/deck_suite_test.go new file mode 100644 index 0000000..4f23b7d --- /dev/null +++ b/cmd/tapes/deck/deck_suite_test.go @@ -0,0 +1,13 @@ +package deckcmder + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDeckCommander(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Deck Commander Suite") +} diff --git a/cmd/tapes/deck/tui.go b/cmd/tapes/deck/tui.go new file mode 100644 index 0000000..d411653 --- /dev/null +++ b/cmd/tapes/deck/tui.go @@ -0,0 +1,1274 @@ +package deckcmder + +import ( + "context" + "fmt" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + bubbletea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/papercomputeco/tapes/pkg/deck" +) + +func init() { + // Force TrueColor profile to fix lipgloss color detection issue + // See: https://github.com/charmbracelet/lipgloss/issues/439 + renderer := lipgloss.NewRenderer(os.Stdout, termenv.WithProfile(termenv.TrueColor)) + renderer.SetColorProfile(termenv.TrueColor) + lipgloss.SetDefaultRenderer(renderer) +} + +type deckView int + +const ( + viewOverview deckView = iota + viewSession +) + +type deckModel struct { + query *deck.Query + filters deck.Filters + overview *deck.DeckOverview + detail *deck.SessionDetail + view deckView + cursor int + messageCursor int + width int + height int + sortIndex int + statusIndex int + messageSort int + trackToggles map[int]bool + replayActive bool + replayOnLoad bool + keys deckKeyMap + help help.Model +} + +var ( + deckTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + deckMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + deckAccentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) + deckDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) + deckSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")) + deckDividerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("237")) + deckMetricLabel = lipgloss.NewStyle().Foreground(lipgloss.Color("246")).Bold(true) + deckMetricValue = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + deckHighlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("235")).Background(lipgloss.Color("214")).Bold(true) + deckStatusOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("70")) + deckStatusFailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + deckStatusWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + deckRoleUserStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("111")) + deckRoleAsstStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) +) + +var sortOrder = []string{"cost", "time", "tokens", "duration"} +var messageSortOrder = []string{"time", "tokens", "cost", "delta"} +var statusFilters = []string{"", deck.StatusCompleted, deck.StatusFailed, deck.StatusAbandoned} + +type deckKeyMap struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Back key.Binding + Sort key.Binding + Filter key.Binding + Track key.Binding + Replay key.Binding + Quit key.Binding +} + +func (k deckKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Down, k.Up, k.Enter, k.Back, k.Sort, k.Filter, k.Track, k.Replay, k.Quit} +} + +func (k deckKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{{k.Down, k.Up, k.Enter, k.Back}, {k.Sort, k.Filter, k.Track, k.Replay, k.Quit}} +} + +func defaultKeyMap() deckKeyMap { + return deckKeyMap{ + Up: key.NewBinding(key.WithKeys("k", "up"), key.WithHelp("k", "up")), + Down: key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("j", "down")), + Enter: key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("enter", "drill")), + Back: key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h", "back")), + Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), + Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "status")), + Track: key.NewBinding(key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8"), key.WithHelp("1-8", "sessions")), + Replay: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "replay")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + } +} + +type sessionLoadedMsg struct { + detail *deck.SessionDetail + err error +} + +type overviewLoadedMsg struct { + overview *deck.DeckOverview + err error +} + +type replayTickMsg time.Time + +func runDeckTUI(ctx context.Context, query *deck.Query, filters deck.Filters) error { + overview, err := query.Overview(ctx, filters) + if err != nil { + return err + } + + model := newDeckModel(query, filters, overview) + + if filters.Session != "" { + detail, err := query.SessionDetail(ctx, filters.Session) + if err != nil { + return err + } + model.view = viewSession + model.detail = detail + } + + program := bubbletea.NewProgram(model, + bubbletea.WithContext(ctx), + bubbletea.WithAltScreen(), + ) + _, err = program.Run() + return err +} + +func newDeckModel(query *deck.Query, filters deck.Filters, overview *deck.DeckOverview) deckModel { + toggles := map[int]bool{} + for i := 0; i < 8; i++ { + toggles[i] = true + } + + sortIndex := 0 + for i, sortKey := range sortOrder { + if sortKey == filters.Sort { + sortIndex = i + } + } + + statusIndex := 0 + for i, status := range statusFilters { + if status == filters.Status { + statusIndex = i + } + } + + return deckModel{ + query: query, + filters: filters, + overview: overview, + view: viewOverview, + trackToggles: toggles, + sortIndex: sortIndex, + statusIndex: statusIndex, + messageSort: 0, + keys: defaultKeyMap(), + help: help.New(), + } +} + +func (m deckModel) Init() bubbletea.Cmd { + return nil +} + +func (m deckModel) Update(msg bubbletea.Msg) (bubbletea.Model, bubbletea.Cmd) { + switch msg := msg.(type) { + case bubbletea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case overviewLoadedMsg: + if msg.err != nil { + return m, nil + } + m.overview = msg.overview + if m.cursor >= len(m.overview.Sessions) { + m.cursor = clamp(m.cursor, 0, len(m.overview.Sessions)-1) + } + return m, nil + case sessionLoadedMsg: + if msg.err != nil { + return m, nil + } + m.detail = msg.detail + m.messageCursor = 0 + m.view = viewSession + m.messageSort = 0 + if m.replayOnLoad { + m.replayOnLoad = false + m.replayActive = true + return m, replayTick() + } + return m, nil + case replayTickMsg: + if !m.replayActive || m.detail == nil { + return m, nil + } + if m.messageCursor >= len(m.sortedMessages())-1 { + m.replayActive = false + return m, nil + } + m.messageCursor++ + return m, replayTick() + case bubbletea.KeyMsg: + return m.handleKey(msg) + } + + return m, nil +} + +func (m deckModel) View() string { + switch m.view { + case viewSession: + return m.viewSession() + default: + return m.viewOverview() + } +} + +func (m deckModel) handleKey(msg bubbletea.KeyMsg) (bubbletea.Model, bubbletea.Cmd) { + switch msg.String() { + case "ctrl+c", "q": + return m, bubbletea.Quit + case "j", "down": + return m.moveCursor(1) + case "k", "up": + return m.moveCursor(-1) + case "l", "enter": + if m.view == viewOverview { + return m.enterSession() + } + case "h", "esc": + if m.view == viewSession { + m.view = viewOverview + m.replayActive = false + } + case "s": + if m.view == viewOverview { + return m.cycleSort() + } + if m.view == viewSession { + return m.cycleMessageSort() + } + case "f": + if m.view == viewOverview { + return m.cycleStatus() + } + case "r": + if m.view == viewSession { + if m.replayActive { + m.replayActive = false + return m, nil + } + m.replayActive = true + m.messageCursor = 0 + return m, replayTick() + } + if m.view == viewOverview { + if len(m.overview.Sessions) == 0 { + return m, nil + } + m.replayOnLoad = true + return m.enterSession() + } + } + + if m.view == viewOverview { + if idx, ok := numberKey(msg.String()); ok { + m.toggleTrack(idx) + } + } + + return m, nil +} + +func (m deckModel) moveCursor(delta int) (bubbletea.Model, bubbletea.Cmd) { + if m.view == viewOverview { + if len(m.overview.Sessions) == 0 { + return m, nil + } + // Limit cursor to first 8 sessions + maxIdx := min(7, len(m.overview.Sessions)-1) + m.cursor = clamp(m.cursor+delta, 0, maxIdx) + return m, nil + } + + if m.detail == nil || len(m.detail.Messages) == 0 { + return m, nil + } + m.messageCursor = clamp(m.messageCursor+delta, 0, len(m.detail.Messages)-1) + return m, nil +} + +func (m deckModel) enterSession() (bubbletea.Model, bubbletea.Cmd) { + if len(m.overview.Sessions) == 0 { + return m, nil + } + + session := m.overview.Sessions[m.cursor] + return m, loadSessionCmd(m.query, session.ID) +} + +func (m deckModel) cycleSort() (bubbletea.Model, bubbletea.Cmd) { + m.sortIndex = (m.sortIndex + 1) % len(sortOrder) + m.filters.Sort = sortOrder[m.sortIndex] + return m, loadOverviewCmd(m.query, m.filters) +} + +func (m deckModel) cycleStatus() (bubbletea.Model, bubbletea.Cmd) { + m.statusIndex = (m.statusIndex + 1) % len(statusFilters) + m.filters.Status = statusFilters[m.statusIndex] + return m, loadOverviewCmd(m.query, m.filters) +} + +func (m deckModel) cycleMessageSort() (bubbletea.Model, bubbletea.Cmd) { + m.messageSort = (m.messageSort + 1) % len(messageSortOrder) + if len(m.sortedMessages()) == 0 { + m.messageCursor = 0 + return m, nil + } + m.messageCursor = clamp(m.messageCursor, 0, len(m.sortedMessages())-1) + return m, nil +} + +func (m deckModel) toggleTrack(idx int) { + if idx < 0 || idx > 7 { + return + } + m.trackToggles[idx] = !m.trackToggles[idx] +} + +func (m deckModel) viewOverview() string { + if m.overview == nil { + return deckMutedStyle.Render("no data") + } + + selected, filtered := m.selectedSessions() + stats := summarizeSessions(selected) + + lastWindow := formatDuration(stats.TotalDuration) + headerLeft := deckTitleStyle.Render("tapes deck") + headerRight := deckMutedStyle.Render(m.headerSessionCount(lastWindow, len(selected), len(m.overview.Sessions), filtered)) + header := renderHeaderLine(m.width, headerLeft, headerRight) + lines := []string{header, renderRule(m.width), ""} + + lines = append(lines, m.viewMetrics(stats)) + lines = append(lines, "", m.viewCostByModel(stats), "", m.viewSessionList(), "", m.viewFooter()) + + return strings.Join(lines, "\n") +} + +func (m deckModel) viewMetrics(stats deckOverviewStats) string { + avgCost := safeDivide(stats.TotalCost, float64(max(1, stats.TotalSessions))) + avgTime := time.Duration(int64(stats.TotalDuration) / int64(max(1, stats.TotalSessions))) + avgTools := stats.TotalToolCalls / max(1, stats.TotalSessions) + + headers := []string{"TOTAL SPEND", "TOKENS USED", "AGENT TIME", "TOOL CALLS", "SUCCESS"} + values := []string{ + formatCost(stats.TotalCost), + fmt.Sprintf("%s in %s out", formatTokens(stats.InputTokens), formatTokens(stats.OutputTokens)), + formatDuration(stats.TotalDuration), + fmt.Sprintf("%d", stats.TotalToolCalls), + formatPercent(stats.SuccessRate), + } + avgValues := []string{ + fmt.Sprintf("%s avg", formatCost(avgCost)), + fmt.Sprintf("%s in %s out", formatTokens(avgTokenCount(stats.InputTokens, stats.TotalSessions)), formatTokens(avgTokenCount(stats.OutputTokens, stats.TotalSessions))), + fmt.Sprintf("%s avg", formatDuration(avgTime)), + fmt.Sprintf("%d avg", avgTools), + fmt.Sprintf("%d/%d", stats.Completed, stats.TotalSessions), + } + + lines := []string{ + renderMetricRow(m.width, headers, deckMetricLabel), + renderMetricRow(m.width, values, deckMetricValue), + deckMutedStyle.Render(renderMetricRow(m.width, avgValues, deckMutedStyle)), + } + + return strings.Join(lines, "\n") +} + +func (m deckModel) viewCostByModel(stats deckOverviewStats) string { + if len(stats.CostByModel) == 0 { + return deckMutedStyle.Render("cost by model: no data") + } + + lines := []string{deckSectionStyle.Render("cost by model"), renderRule(m.width)} + maxCost := 0.0 + for _, cost := range stats.CostByModel { + if cost.TotalCost > maxCost { + maxCost = cost.TotalCost + } + } + + for _, cost := range sortedModelCosts(stats.CostByModel) { + bar := renderBar(cost.TotalCost, maxCost, 24) + line := fmt.Sprintf("* %-16s %s %s %d sessions", cost.Model, deckAccentStyle.Render(bar), formatCost(cost.TotalCost), cost.SessionCount) + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +func (m deckModel) viewSessionList() string { + if len(m.overview.Sessions) == 0 { + return deckMutedStyle.Render("sessions: no data") + } + + // Limit to 8 visible sessions + maxVisible := 8 + if len(m.overview.Sessions) < maxVisible { + maxVisible = len(m.overview.Sessions) + } + + status := m.filters.Status + if status == "" { + status = "all" + } + lines := []string{deckSectionStyle.Render(fmt.Sprintf("sessions (sort: %s, status: %s)", m.filters.Sort, status)), renderRule(m.width)} + lines = append(lines, deckMutedStyle.Render(" label model dur tokens cost tools msgs status")) + for i := 0; i < maxVisible; i++ { + session := m.overview.Sessions[i] + cursor := " " + if i == m.cursor { + cursor = ">" + } + + toggle := " " + if m.trackToggles[i] { + toggle = fmt.Sprintf("%d", i+1) + } else { + toggle = "-" + } + + statusValue := session.Status + statusStyle := statusStyleFor(statusValue) + line := fmt.Sprintf("%s %s %-14s %-12s %6s %9s %8s %5d %4d %s", + cursor, + toggle, + truncateText(session.Label, 14), + truncateText(session.Model, 12), + formatDuration(session.Duration), + formatTokens(session.InputTokens+session.OutputTokens), + formatCost(session.TotalCost), + session.ToolCalls, + session.MessageCount, + statusStyle.Render(statusValue), + ) + + if !m.trackToggles[i] { + line = deckDimStyle.Render(line) + } + if i == m.cursor { + line = deckHighlightStyle.Render(line) + } + + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +func (m deckModel) viewSession() string { + if m.detail == nil { + return deckMutedStyle.Render("no session selected") + } + + statusStyle := statusStyleFor(m.detail.Summary.Status) + statusDot := statusStyle.Render("●") + headerLeft := deckTitleStyle.Render(fmt.Sprintf("⏏ tapes deck › %s", m.detail.Summary.Label)) + headerRight := deckMutedStyle.Render(fmt.Sprintf("%s · %s %s", m.detail.Summary.ID, statusDot, m.detail.Summary.Status)) + header := renderHeaderLine(m.width, headerLeft, headerRight) + lines := []string{header, renderRule(m.width), ""} + + lines = append(lines, deckSectionStyle.Render("session"), renderRule(m.width)) + lines = append(lines, deckMutedStyle.Render("MODEL DURATION INPUT COST OUTPUT COST TOTAL")) + lines = append(lines, fmt.Sprintf("%-15s %-15s %-14s %-14s %s", + truncateText(m.detail.Summary.Model, 15), + formatDuration(m.detail.Summary.Duration), + formatCost(m.detail.Summary.InputCost), + formatCost(m.detail.Summary.OutputCost), + formatCost(m.detail.Summary.TotalCost), + )) + + lines = append(lines, deckMutedStyle.Render(fmt.Sprintf("%-15s %-15s %-14s %-14s", + "", + "", + fmt.Sprintf("%s tokens", formatTokens(m.detail.Summary.InputTokens)), + fmt.Sprintf("%s tokens", formatTokens(m.detail.Summary.OutputTokens)), + ))) + + inputRate, outputRate := costPerMTok(m.detail.Summary.InputCost, m.detail.Summary.InputTokens), costPerMTok(m.detail.Summary.OutputCost, m.detail.Summary.OutputTokens) + lines = append(lines, deckMutedStyle.Render(fmt.Sprintf("%-15s %-15s $%.2f/$%.2f MTok", + "", + "", + inputRate, + outputRate, + ))) + + inputPercent, outputPercent := splitPercent(m.detail.Summary.InputCost, m.detail.Summary.OutputCost) + lines = append(lines, "") + lines = append(lines, renderSplitBar("input", inputPercent, 26)) + lines = append(lines, renderSplitBar("output", outputPercent, 26)) + + costPerMessage := safeDivide(m.detail.Summary.TotalCost, float64(max(1, m.detail.Summary.MessageCount))) + costPerMinute := costPerMinute(m.detail.Summary.TotalCost, m.detail.Summary.Duration) + toolsPerTurn := float64(m.detail.Summary.ToolCalls) / float64(max(1, m.detail.Summary.MessageCount)) + lines = append(lines, "") + lines = append(lines, deckMutedStyle.Render(fmt.Sprintf("cost/message: %s cost/minute: %s tools/turn: %.1f", + formatCost(costPerMessage), + formatCost(costPerMinute), + toolsPerTurn, + ))) + + screenHeight := m.height + if screenHeight <= 0 { + screenHeight = 40 + } + footerHeight := 2 + remaining := screenHeight - len(lines) - footerHeight + if remaining < 8 { + remaining = 8 + } + gap := 3 + leftWidth := (m.width - gap) * 2 / 3 + if leftWidth < 30 { + leftWidth = 30 + } + rightWidth := m.width - gap - leftWidth + if rightWidth < 24 { + rightWidth = 24 + leftWidth = m.width - gap - rightWidth + } + + leftBlock := m.renderTimelineBlock(leftWidth, remaining) + rightBlock := m.renderDetailBlock(rightWidth, remaining) + lines = append(lines, joinColumns(leftBlock, rightBlock, gap)...) + + lines = append(lines, "", m.viewSessionFooter()) + + return strings.Join(lines, "\n") +} + +func (m deckModel) viewFooter() string { + return deckMutedStyle.Render(m.help.View(m.keys)) +} + +func (m deckModel) viewSessionFooter() string { + return deckMutedStyle.Render(m.help.View(m.keys)) +} + +func loadOverviewCmd(query *deck.Query, filters deck.Filters) bubbletea.Cmd { + return func() bubbletea.Msg { + overview, err := query.Overview(context.Background(), filters) + return overviewLoadedMsg{overview: overview, err: err} + } +} + +func loadSessionCmd(query *deck.Query, sessionID string) bubbletea.Cmd { + return func() bubbletea.Msg { + detail, err := query.SessionDetail(context.Background(), sessionID) + return sessionLoadedMsg{detail: detail, err: err} + } +} + +func replayTick() bubbletea.Cmd { + return bubbletea.Tick(300*time.Millisecond, func(t time.Time) bubbletea.Msg { + return replayTickMsg(t) + }) +} + +func sortedModelCosts(costs map[string]deck.ModelCost) []deck.ModelCost { + items := make([]deck.ModelCost, 0, len(costs)) + for _, cost := range costs { + items = append(items, cost) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].TotalCost > items[j].TotalCost + }) + + return items +} + +func numberKey(key string) (int, bool) { + switch key { + case "1", "2", "3", "4", "5", "6", "7", "8": + return int(key[0] - '1'), true + default: + return 0, false + } +} + +func clamp(value, min, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} + +func formatCost(value float64) string { + return fmt.Sprintf("$%.3f", value) +} + +func formatTokens(value int64) string { + if value >= 1_000_000 { + return fmt.Sprintf("%.1fM", float64(value)/1_000_000.0) + } + if value >= 1_000 { + return fmt.Sprintf("%.1fK", float64(value)/1_000.0) + } + return fmt.Sprintf("%d", value) +} + +func formatDuration(value time.Duration) string { + if value <= 0 { + return "0s" + } + + minutes := int(value.Minutes()) + seconds := int(value.Seconds()) % 60 + hours := minutes / 60 + minutes = minutes % 60 + if hours > 0 { + return fmt.Sprintf("%dh%dm", hours, minutes) + } + if minutes > 0 { + return fmt.Sprintf("%dm%ds", minutes, seconds) + } + return fmt.Sprintf("%ds", seconds) +} + +func formatPercent(value float64) string { + return fmt.Sprintf("%.0f%%", value*100) +} + +func truncateText(value string, limit int) string { + if len(value) <= limit { + return value + } + if limit <= 3 { + return value[:limit] + } + return value[:limit-3] + "..." +} + +func renderBar(value, max float64, width int) string { + if max <= 0 { + return strings.Repeat("░", width) + } + ratio := value / max + filled := int(ratio * float64(width)) + if filled < 0 { + filled = 0 + } + if filled > width { + filled = width + } + return strings.Repeat("█", filled) + strings.Repeat("░", width-filled) +} + +func renderHeaderLine(width int, left, right string) string { + lineWidth := width + if lineWidth <= 0 { + lineWidth = 80 + } + leftWidth := lipgloss.Width(left) + rightWidth := lipgloss.Width(right) + if leftWidth+rightWidth+1 >= lineWidth { + return strings.TrimSpace(left + " " + right) + } + spacing := lineWidth - leftWidth - rightWidth + return left + strings.Repeat(" ", spacing) + right +} + +func renderRule(width int) string { + lineWidth := width + if lineWidth <= 0 { + lineWidth = 80 + } + return deckDividerStyle.Render(strings.Repeat("─", lineWidth)) +} + +func renderMetricRow(width int, items []string, style lipgloss.Style) string { + if len(items) == 0 { + return "" + } + lineWidth := width + if lineWidth <= 0 { + lineWidth = 80 + } + cols := len(items) + spaceWidth := (cols - 1) * 2 + colWidth := (lineWidth - spaceWidth) / cols + if colWidth < 12 { + colWidth = 12 + } + parts := make([]string, 0, len(items)) + for _, item := range items { + parts = append(parts, style.Render(fitCell(item, colWidth))) + } + return strings.Join(parts, " ") +} + +func fitCell(value string, width int) string { + if width <= 0 { + return value + } + if lipgloss.Width(value) > width { + return truncateText(value, width) + } + return value + strings.Repeat(" ", width-lipgloss.Width(value)) +} + +func avgTokenCount(total int64, count int) int64 { + if count <= 0 { + return 0 + } + return total / int64(count) +} + +func statusStyleFor(status string) lipgloss.Style { + switch status { + case deck.StatusCompleted: + return deckStatusOKStyle + case deck.StatusFailed: + return deckStatusFailStyle + case deck.StatusAbandoned: + return deckStatusWarnStyle + default: + return deckMutedStyle + } +} + +func splitPercent(inputCost, outputCost float64) (float64, float64) { + total := inputCost + outputCost + if total <= 0 { + return 0, 0 + } + return (inputCost / total) * 100, (outputCost / total) * 100 +} + +func renderSplitBar(label string, percent float64, width int) string { + if width <= 0 { + width = 24 + } + filled := int((percent / 100) * float64(width)) + if filled < 0 { + filled = 0 + } + if filled > width { + filled = width + } + bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + return fmt.Sprintf("%s %s %2.0f%%", label, bar, percent) +} + +func renderSectionDivider(width int, title string) string { + lineWidth := width + if lineWidth <= 0 { + lineWidth = 80 + } + label := fmt.Sprintf("─── %s ", title) + remaining := lineWidth - lipgloss.Width(label) - 2 + if remaining < 0 { + return " " + label + } + return " " + label + strings.Repeat("─", remaining) +} + +func (m deckModel) renderTimelineBlock(width, height int) []string { + lines := []string{renderSectionDivider(width, fmt.Sprintf("timeline (sort: %s)", messageSortOrder[m.messageSort]))} + if m.detail == nil || len(m.detail.Messages) == 0 { + lines = append(lines, deckMutedStyle.Render("no messages")) + return padLines(lines, width, height) + } + if height < 3 { + height = 3 + } + maxVisible := height - 1 + if maxVisible < 1 { + maxVisible = 1 + } + + messages := m.sortedMessages() + start, end := visibleRange(len(messages), m.messageCursor, maxVisible) + for i := start; i < end; i++ { + msg := messages[i] + cursor := " " + if i == m.messageCursor { + cursor = ">" + } + + role := roleLabel(msg.Role) + tools := formatTools(msg.ToolCalls) + line := renderTimelineLine( + width, + cursor, + msg.Timestamp.Format("15:04:05"), + role, + formatTokensDetail(msg.TotalTokens), + formatCost(msg.TotalCost), + tools, + formatDelta(msg.Delta), + ) + + if i == m.messageCursor { + line = deckHighlightStyle.Render(line) + } + + lines = append(lines, line) + } + + return padLines(lines, width, height) +} + +func (m deckModel) renderDetailBlock(width, height int) []string { + lines := []string{renderSectionDivider(width, "details | message")} + if m.detail == nil || len(m.detail.Messages) == 0 { + lines = append(lines, deckMutedStyle.Render("no message")) + return padLines(lines, width, height) + } + if height < 3 { + height = 3 + } + messages := m.sortedMessages() + if len(messages) == 0 { + lines = append(lines, deckMutedStyle.Render("no message")) + return padLines(lines, width, height) + } + msg := messages[m.messageCursor] + role := roleLabel(msg.Role) + + lines = append(lines, + fmt.Sprintf("role: %s", role), + fmt.Sprintf("time: %s delta: %s", msg.Timestamp.Format("15:04:05"), formatDelta(msg.Delta)), + fmt.Sprintf("tokens: in %s out %s total %s", formatTokensDetail(msg.InputTokens), formatTokensDetail(msg.OutputTokens), formatTokensDetail(msg.TotalTokens)), + fmt.Sprintf("cost: in %s out %s total %s", formatCost(msg.InputCost), formatCost(msg.OutputCost), formatCost(msg.TotalCost)), + ) + + tools := "" + if len(msg.ToolCalls) > 0 { + tools = "tools: " + strings.Join(msg.ToolCalls, " ") + } + if tools != "" { + lines = append(lines, wrapText(tools, max(20, width-2))...) + } + + text := strings.TrimSpace(msg.Text) + if text != "" { + lines = append(lines, "message:") + lines = append(lines, wrapText(text, max(20, width-2))...) + } + + return padLines(lines, width, height) +} + +func renderTimelineLine(width int, cursor, timestamp, role, tokens, cost, tools, delta string) string { + lineWidth := width + if lineWidth <= 0 { + lineWidth = 80 + } + gap := 2 + cursorWidth := 1 + timeWidth := 8 + roleWidth := 12 + tokensWidth := 9 + costWidth := 7 + deltaWidth := 6 + baseWidth := cursorWidth + timeWidth + roleWidth + tokensWidth + costWidth + deltaWidth + gap*6 + toolWidth := lineWidth - baseWidth + if toolWidth < 0 { + toolWidth = 0 + } + + columns := []string{ + fitCell(cursor, cursorWidth), + fitCell(timestamp, timeWidth), + fitCell(role, roleWidth), + fitCellRight(tokens, tokensWidth), + fitCellRight(cost, costWidth), + fitCellRight(delta, deltaWidth), + } + + if toolWidth > 0 { + columns = append(columns, fitCell(truncateText(tools, toolWidth), toolWidth)) + } else { + columns = append(columns, "") + } + + return strings.Join(columns, " ") +} + +type deckOverviewStats struct { + TotalSessions int + TotalCost float64 + InputTokens int64 + OutputTokens int64 + TotalDuration time.Duration + TotalToolCalls int + SuccessRate float64 + Completed int + Failed int + Abandoned int + CostByModel map[string]deck.ModelCost +} + +func summarizeSessions(sessions []deck.SessionSummary) deckOverviewStats { + stats := deckOverviewStats{ + TotalSessions: len(sessions), + CostByModel: map[string]deck.ModelCost{}, + } + for _, session := range sessions { + stats.TotalCost += session.TotalCost + stats.InputTokens += session.InputTokens + stats.OutputTokens += session.OutputTokens + stats.TotalDuration += session.Duration + stats.TotalToolCalls += session.ToolCalls + switch session.Status { + case deck.StatusCompleted: + stats.Completed++ + case deck.StatusFailed: + stats.Failed++ + case deck.StatusAbandoned: + stats.Abandoned++ + } + + modelCost := stats.CostByModel[session.Model] + modelCost.Model = session.Model + modelCost.InputTokens += session.InputTokens + modelCost.OutputTokens += session.OutputTokens + modelCost.InputCost += session.InputCost + modelCost.OutputCost += session.OutputCost + modelCost.TotalCost += session.TotalCost + modelCost.SessionCount++ + stats.CostByModel[session.Model] = modelCost + } + if stats.TotalSessions > 0 { + stats.SuccessRate = float64(stats.Completed) / float64(stats.TotalSessions) + } + return stats +} + +func (m deckModel) selectedSessions() ([]deck.SessionSummary, bool) { + if m.overview == nil || len(m.overview.Sessions) == 0 { + return nil, false + } + + maxVisible := min(7, len(m.overview.Sessions)-1) + filtered := false + for i := 0; i <= maxVisible; i++ { + if !m.trackToggles[i] { + filtered = true + break + } + } + if !filtered { + return m.overview.Sessions, false + } + + selected := make([]deck.SessionSummary, 0, maxVisible+1) + for i := 0; i <= maxVisible; i++ { + if m.trackToggles[i] { + selected = append(selected, m.overview.Sessions[i]) + } + } + + return selected, true +} + +func (m deckModel) headerSessionCount(lastWindow string, selected, total int, filtered bool) string { + if filtered { + return fmt.Sprintf("last %s · %d/%d sessions", lastWindow, selected, total) + } + return fmt.Sprintf("last %s · %d sessions", lastWindow, total) +} + +func (m deckModel) sortedMessages() []deck.SessionMessage { + if m.detail == nil || len(m.detail.Messages) == 0 { + return nil + } + messages := make([]deck.SessionMessage, len(m.detail.Messages)) + copy(messages, m.detail.Messages) + sortKey := messageSortOrder[m.messageSort%len(messageSortOrder)] + sort.SliceStable(messages, func(i, j int) bool { + switch sortKey { + case "tokens": + if messages[i].TotalTokens == messages[j].TotalTokens { + return messages[i].Timestamp.Before(messages[j].Timestamp) + } + return messages[i].TotalTokens > messages[j].TotalTokens + case "cost": + if messages[i].TotalCost == messages[j].TotalCost { + return messages[i].Timestamp.Before(messages[j].Timestamp) + } + return messages[i].TotalCost > messages[j].TotalCost + case "delta": + if messages[i].Delta == messages[j].Delta { + return messages[i].Timestamp.Before(messages[j].Timestamp) + } + return messages[i].Delta > messages[j].Delta + default: + return messages[i].Timestamp.Before(messages[j].Timestamp) + } + }) + + return messages +} + +func padLines(lines []string, width, height int) []string { + if height <= 0 { + return []string{} + } + if width <= 0 { + width = 1 + } + result := make([]string, 0, height) + for _, line := range lines { + result = append(result, padRight(line, width)) + if len(result) >= height { + return result[:height] + } + } + for len(result) < height { + result = append(result, strings.Repeat(" ", width)) + } + return result +} + +func padRight(value string, width int) string { + lineWidth := lipgloss.Width(value) + if lineWidth >= width { + return value + } + return value + strings.Repeat(" ", width-lineWidth) +} + +func joinColumns(left, right []string, gap int) []string { + maxLines := len(left) + if len(right) > maxLines { + maxLines = len(right) + } + lines := make([]string, 0, maxLines) + gapSpace := strings.Repeat(" ", gap) + for i := 0; i < maxLines; i++ { + leftLine := "" + if i < len(left) { + leftLine = left[i] + } + rightLine := "" + if i < len(right) { + rightLine = right[i] + } + lines = append(lines, leftLine+gapSpace+rightLine) + } + return lines +} + +func visibleRange(total, cursor, size int) (int, int) { + if total <= 0 || size <= 0 { + return 0, 0 + } + if total <= size { + return 0, total + } + if cursor < 0 { + cursor = 0 + } + if cursor >= total { + cursor = total - 1 + } + start := cursor - (size / 2) + if start < 0 { + start = 0 + } + end := start + size + if end > total { + end = total + start = end - size + if start < 0 { + start = 0 + } + } + return start, end +} + +func costPerMTok(cost float64, tokens int64) float64 { + if tokens <= 0 { + return 0 + } + return (cost / float64(tokens)) * 1_000_000 +} + +func safeDivide(value, divisor float64) float64 { + if divisor == 0 { + return 0 + } + return value / divisor +} + +func costPerMinute(totalCost float64, duration time.Duration) float64 { + minutes := duration.Minutes() + if minutes <= 0 { + return 0 + } + return totalCost / minutes +} + +func formatDelta(value time.Duration) string { + if value <= 0 { + return "" + } + return "+" + formatDuration(value) +} + +func formatTokensDetail(value int64) string { + if value < 10_000 { + return fmt.Sprintf("%s tok", formatInt(value)) + } + return fmt.Sprintf("%s tok", formatTokens(value)) +} + +func formatInt(value int64) string { + str := strconv.FormatInt(value, 10) + if len(str) <= 3 { + return str + } + var parts []string + for len(str) > 3 { + parts = append([]string{str[len(str)-3:]}, parts...) + str = str[:len(str)-3] + } + if str != "" { + parts = append([]string{str}, parts...) + } + return strings.Join(parts, ",") +} + +func formatTools(toolCalls []string) string { + if len(toolCalls) == 0 { + return "" + } + list := strings.Join(toolCalls, " ") + return "⚡" + list +} + +func roleLabel(role string) string { + switch role { + case "assistant": + return deckRoleAsstStyle.Render("● assistant") + case "user": + return deckRoleUserStyle.Render("○ user") + default: + return role + } +} + +func fitCellRight(value string, width int) string { + if width <= 0 { + return value + } + if lipgloss.Width(value) >= width { + return value + } + return strings.Repeat(" ", width-lipgloss.Width(value)) + value +} + +func wrapText(text string, width int) []string { + if width <= 0 { + return []string{text} + } + words := strings.Fields(text) + if len(words) == 0 { + return []string{""} + } + lines := []string{} + current := "" + for _, word := range words { + if current == "" { + current = word + continue + } + if lipgloss.Width(current)+1+lipgloss.Width(word) <= width { + current = current + " " + word + continue + } + lines = append(lines, current) + current = word + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +func (m deckModel) viewToolFrequency() string { + if m.detail == nil || len(m.detail.ToolFrequency) == 0 { + return deckMutedStyle.Render("no tools recorded") + } + + items := make([]toolCount, 0, len(m.detail.ToolFrequency)) + maxCount := 0 + for tool, count := range m.detail.ToolFrequency { + items = append(items, toolCount{name: tool, count: count}) + if count > maxCount { + maxCount = count + } + } + + sort.Slice(items, func(i, j int) bool { + if items[i].count == items[j].count { + return items[i].name < items[j].name + } + return items[i].count > items[j].count + }) + + maxVisible := 6 + if len(items) < maxVisible { + maxVisible = len(items) + } + + lines := make([]string, 0, maxVisible) + for i := 0; i < maxVisible; i++ { + item := items[i] + bar := renderBar(float64(item.count), float64(maxCount), 24) + line := fmt.Sprintf("* %-16s %s %d", item.name, deckAccentStyle.Render(bar), item.count) + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +type toolCount struct { + name string + count int +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/tapes/deck/tui_test.go b/cmd/tapes/deck/tui_test.go new file mode 100644 index 0000000..8222b1d --- /dev/null +++ b/cmd/tapes/deck/tui_test.go @@ -0,0 +1,214 @@ +package deckcmder + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/papercomputeco/tapes/pkg/deck" +) + +var _ = Describe("Deck TUI helpers", func() { + Describe("summarizeSessions", func() { + It("rolls up totals and model costs", func() { + sessions := []deck.SessionSummary{ + { + ID: "s1", + Model: "m1", + Status: deck.StatusCompleted, + Duration: 2 * time.Minute, + InputTokens: 100, + OutputTokens: 50, + InputCost: 0.10, + OutputCost: 0.20, + TotalCost: 0.30, + ToolCalls: 2, + }, + { + ID: "s2", + Model: "m2", + Status: deck.StatusFailed, + Duration: 1 * time.Minute, + InputTokens: 10, + OutputTokens: 5, + InputCost: 0.05, + OutputCost: 0.05, + TotalCost: 0.10, + ToolCalls: 1, + }, + { + ID: "s3", + Model: "m1", + Status: deck.StatusAbandoned, + Duration: 3 * time.Minute, + InputTokens: 20, + OutputTokens: 30, + InputCost: 0.02, + OutputCost: 0.03, + TotalCost: 0.05, + ToolCalls: 0, + }, + } + + stats := summarizeSessions(sessions) + Expect(stats.TotalSessions).To(Equal(3)) + Expect(stats.TotalCost).To(BeNumerically("~", 0.45, 0.0001)) + Expect(stats.InputTokens).To(Equal(int64(130))) + Expect(stats.OutputTokens).To(Equal(int64(85))) + Expect(stats.TotalDuration).To(Equal(6 * time.Minute)) + Expect(stats.TotalToolCalls).To(Equal(3)) + Expect(stats.Completed).To(Equal(1)) + Expect(stats.Failed).To(Equal(1)) + Expect(stats.Abandoned).To(Equal(1)) + Expect(stats.SuccessRate).To(BeNumerically("~", 1.0/3.0, 0.0001)) + Expect(stats.CostByModel).To(HaveKey("m1")) + Expect(stats.CostByModel).To(HaveKey("m2")) + Expect(stats.CostByModel["m1"].SessionCount).To(Equal(2)) + Expect(stats.CostByModel["m1"].TotalCost).To(BeNumerically("~", 0.35, 0.0001)) + Expect(stats.CostByModel["m2"].SessionCount).To(Equal(1)) + Expect(stats.CostByModel["m2"].TotalCost).To(BeNumerically("~", 0.10, 0.0001)) + }) + }) + + Describe("selectedSessions", func() { + It("returns all sessions when nothing is toggled off", func() { + sessions := []deck.SessionSummary{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}} + model := deckModel{ + overview: &deck.DeckOverview{Sessions: sessions}, + trackToggles: map[int]bool{ + 0: true, + 1: true, + 2: true, + 3: true, + 4: true, + 5: true, + 6: true, + 7: true, + }, + } + + selected, filtered := model.selectedSessions() + Expect(filtered).To(BeFalse()) + Expect(selected).To(HaveLen(3)) + }) + + It("excludes deselected sessions", func() { + sessions := []deck.SessionSummary{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}} + model := deckModel{ + overview: &deck.DeckOverview{Sessions: sessions}, + trackToggles: map[int]bool{ + 0: true, + 1: false, + 2: false, + 3: true, + 4: true, + 5: true, + 6: true, + 7: true, + }, + } + + selected, filtered := model.selectedSessions() + Expect(filtered).To(BeTrue()) + Expect(selected).To(HaveLen(1)) + Expect(selected[0].ID).To(Equal("s1")) + }) + }) + + Describe("sortedMessages", func() { + var messages []deck.SessionMessage + + BeforeEach(func() { + base := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + messages = []deck.SessionMessage{ + { + Hash: "m1", + Timestamp: base.Add(1 * time.Second), + TotalTokens: 100, + TotalCost: 0.20, + Delta: 5 * time.Second, + }, + { + Hash: "m2", + Timestamp: base.Add(2 * time.Second), + TotalTokens: 50, + TotalCost: 0.30, + Delta: 1 * time.Second, + }, + { + Hash: "m3", + Timestamp: base.Add(3 * time.Second), + TotalTokens: 200, + TotalCost: 0.10, + Delta: 10 * time.Second, + }, + } + }) + + It("sorts by time", func() { + model := deckModel{ + detail: &deck.SessionDetail{Messages: messages}, + messageSort: 0, + } + ordered := model.sortedMessages() + Expect(ordered).To(HaveLen(3)) + Expect(ordered[0].Hash).To(Equal("m1")) + Expect(ordered[1].Hash).To(Equal("m2")) + Expect(ordered[2].Hash).To(Equal("m3")) + }) + + It("sorts by tokens", func() { + model := deckModel{ + detail: &deck.SessionDetail{Messages: messages}, + messageSort: 1, + } + ordered := model.sortedMessages() + Expect(ordered[0].Hash).To(Equal("m3")) + Expect(ordered[1].Hash).To(Equal("m1")) + Expect(ordered[2].Hash).To(Equal("m2")) + }) + + It("sorts by cost", func() { + model := deckModel{ + detail: &deck.SessionDetail{Messages: messages}, + messageSort: 2, + } + ordered := model.sortedMessages() + Expect(ordered[0].Hash).To(Equal("m2")) + Expect(ordered[1].Hash).To(Equal("m1")) + Expect(ordered[2].Hash).To(Equal("m3")) + }) + + It("sorts by delta", func() { + model := deckModel{ + detail: &deck.SessionDetail{Messages: messages}, + messageSort: 3, + } + ordered := model.sortedMessages() + Expect(ordered[0].Hash).To(Equal("m3")) + Expect(ordered[1].Hash).To(Equal("m1")) + Expect(ordered[2].Hash).To(Equal("m2")) + }) + }) + + Describe("visibleRange", func() { + It("centers around the cursor", func() { + start, end := visibleRange(10, 5, 4) + Expect(start).To(Equal(3)) + Expect(end).To(Equal(7)) + }) + + It("clamps to the start", func() { + start, end := visibleRange(10, 0, 3) + Expect(start).To(Equal(0)) + Expect(end).To(Equal(3)) + }) + + It("clamps to the end", func() { + start, end := visibleRange(10, 9, 3) + Expect(start).To(Equal(7)) + Expect(end).To(Equal(10)) + }) + }) +}) diff --git a/cmd/tapes/deck/web.go b/cmd/tapes/deck/web.go new file mode 100644 index 0000000..971e774 --- /dev/null +++ b/cmd/tapes/deck/web.go @@ -0,0 +1,76 @@ +package deckcmder + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/papercomputeco/tapes/pkg/deck" + deckweb "github.com/papercomputeco/tapes/web/deck" +) + +func runDeckWeb(ctx context.Context, query *deck.Query, filters deck.Filters, port int) error { + address := fmt.Sprintf("127.0.0.1:%d", port) + + mux := http.NewServeMux() + mux.HandleFunc("/api/overview", func(w http.ResponseWriter, r *http.Request) { + overview, err := query.Overview(r.Context(), filters) + if err != nil { + writeJSONError(w, err) + return + } + writeJSON(w, overview) + }) + + mux.HandleFunc("/api/session/", func(w http.ResponseWriter, r *http.Request) { + sessionID := strings.TrimPrefix(r.URL.Path, "/api/session/") + if sessionID == "" { + http.Error(w, "missing session id", http.StatusBadRequest) + return + } + + detail, err := query.SessionDetail(r.Context(), sessionID) + if err != nil { + writeJSONError(w, err) + return + } + writeJSON(w, detail) + }) + + fileServer := http.FileServer(http.FS(deckweb.FS)) + mux.Handle("/", fileServer) + + server := &http.Server{ + Addr: address, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + listener, err := net.Listen("tcp", address) + if err != nil { + return err + } + + fmt.Printf("deck web running at http://%s\n", address) + + go func() { + <-ctx.Done() + _ = server.Shutdown(context.Background()) + }() + + return server.Serve(listener) +} + +func writeJSON(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(payload) +} + +func writeJSONError(w http.ResponseWriter, err error) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) +} diff --git a/cmd/tapes/tapes.go b/cmd/tapes/tapes.go index 83dffb8..298c21c 100644 --- a/cmd/tapes/tapes.go +++ b/cmd/tapes/tapes.go @@ -6,6 +6,7 @@ import ( chatcmder "github.com/papercomputeco/tapes/cmd/tapes/chat" checkoutcmder "github.com/papercomputeco/tapes/cmd/tapes/checkout" + deckcmder "github.com/papercomputeco/tapes/cmd/tapes/deck" initcmder "github.com/papercomputeco/tapes/cmd/tapes/init" searchcmder "github.com/papercomputeco/tapes/cmd/tapes/search" servecmder "github.com/papercomputeco/tapes/cmd/tapes/serve" @@ -28,7 +29,11 @@ Experimental: Chat through the proxy: tapes init Initialize a local .tapes directory Search sessions: - tapes search Search sessions using semantic similarity` + tapes search Search sessions using semantic similarity + +Deck sessions: + tapes deck ROI dashboard for sessions + tapes deck --web Local web dashboard` const tapesShortDesc string = "Tapes - Agent Telemetry" @@ -45,6 +50,7 @@ func NewTapesCmd() *cobra.Command { // Add subcommands cmd.AddCommand(chatcmder.NewChatCmd()) cmd.AddCommand(checkoutcmder.NewCheckoutCmd()) + cmd.AddCommand(deckcmder.NewDeckCmd()) cmd.AddCommand(initcmder.NewInitCmd()) cmd.AddCommand(searchcmder.NewSearchCmd()) cmd.AddCommand(servecmder.NewServeCmd()) diff --git a/go.mod b/go.mod index cd77006..884080d 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,17 @@ require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/inflect v0.19.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect @@ -32,13 +42,21 @@ require ( github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/valyala/fasthttp v1.62.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zclconf/go-cty v1.14.4 // indirect diff --git a/go.sum b/go.sum index a4b5896..baad2c4 100644 --- a/go.sum +++ b/go.sum @@ -12,13 +12,33 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI7KqjwVxV8RNoww= github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q= github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -63,12 +83,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= @@ -79,6 +103,14 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= @@ -89,6 +121,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -112,6 +146,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -136,6 +172,7 @@ golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/pkg/deck/pricing.go b/pkg/deck/pricing.go new file mode 100644 index 0000000..172e70d --- /dev/null +++ b/pkg/deck/pricing.go @@ -0,0 +1,96 @@ +package deck + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +type PricingTable map[string]Pricing + +func DefaultPricing() PricingTable { + return PricingTable{ + "claude-opus-4.5": {Input: 5.00, Output: 25.00}, + "claude-sonnet-4.5": {Input: 3.00, Output: 15.00}, + "claude-sonnet-4": {Input: 3.00, Output: 15.00}, + "claude-haiku-4.5": {Input: 1.00, Output: 5.00}, + "claude-3.5-sonnet": {Input: 3.00, Output: 15.00}, + "claude-3.5-haiku": {Input: 1.00, Output: 5.00}, + "claude-3-opus": {Input: 15.00, Output: 75.00}, + "gpt-4o": {Input: 2.50, Output: 10.00}, + "gpt-4o-mini": {Input: 0.15, Output: 0.60}, + "deepseek-r1": {Input: 0.55, Output: 2.19}, + "claude-opus-4-5": {Input: 5.00, Output: 25.00}, + "claude-sonnet-4-5": {Input: 3.00, Output: 15.00}, + "claude-haiku-4-5": {Input: 1.00, Output: 5.00}, + "claude-3-5-sonnet": {Input: 3.00, Output: 15.00}, + "claude-3-5-haiku": {Input: 1.00, Output: 5.00}, + } +} + +func LoadPricing(path string) (PricingTable, error) { + pricing := DefaultPricing() + if path == "" { + return pricing, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read pricing file: %w", err) + } + + var overrides map[string]Pricing + if err := json.Unmarshal(data, &overrides); err != nil { + return nil, fmt.Errorf("parse pricing file: %w", err) + } + + for model, price := range overrides { + pricing[model] = price + } + + return pricing, nil +} + +func PricingForModel(pricing PricingTable, model string) (Pricing, bool) { + normalized := normalizeModel(model) + price, ok := pricing[normalized] + if ok { + return price, true + } + price, ok = pricing[model] + return price, ok +} + +func CostForTokens(pricing Pricing, inputTokens, outputTokens int64) (float64, float64, float64) { + inputCost := float64(inputTokens) / 1_000_000.0 * pricing.Input + outputCost := float64(outputTokens) / 1_000_000.0 * pricing.Output + return inputCost, outputCost, inputCost + outputCost +} + +func normalizeModel(model string) string { + normalized := strings.ToLower(strings.TrimSpace(model)) + if normalized == "" { + return normalized + } + + if idx := strings.LastIndex(normalized, "-"); idx != -1 { + suffix := normalized[idx+1:] + if len(suffix) == 8 && isDigits(suffix) { + normalized = normalized[:idx] + } + } + + normalized = strings.ReplaceAll(normalized, "-4-5", "-4.5") + normalized = strings.ReplaceAll(normalized, "-3-5", "-3.5") + return normalized +} + +func isDigits(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/pkg/deck/query.go b/pkg/deck/query.go new file mode 100644 index 0000000..9abe1a5 --- /dev/null +++ b/pkg/deck/query.go @@ -0,0 +1,497 @@ +package deck + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/papercomputeco/tapes/pkg/llm" + "github.com/papercomputeco/tapes/pkg/storage/ent" + "github.com/papercomputeco/tapes/pkg/storage/ent/node" + "github.com/papercomputeco/tapes/pkg/storage/sqlite" +) + +type Query struct { + client *ent.Client + pricing PricingTable +} + +func NewQuery(dbPath string, pricing PricingTable) (*Query, func() error, error) { + driver, err := sqlite.NewSQLiteDriver(dbPath) + if err != nil { + return nil, nil, err + } + + closeFn := func() error { + return driver.Close() + } + + return &Query{client: driver.Client, pricing: pricing}, closeFn, nil +} + +func (q *Query) Overview(ctx context.Context, filters Filters) (*DeckOverview, error) { + leaves, err := q.client.Node.Query().Where(node.Not(node.HasChildren())).All(ctx) + if err != nil { + return nil, fmt.Errorf("list leaves: %w", err) + } + + overview := &DeckOverview{ + Sessions: make([]SessionSummary, 0, len(leaves)), + CostByModel: map[string]ModelCost{}, + } + + for _, leaf := range leaves { + summary, modelCosts, status, err := q.buildSessionSummary(ctx, leaf) + if err != nil { + return nil, err + } + + if !matchesFilters(summary, filters) { + continue + } + + overview.Sessions = append(overview.Sessions, summary) + + overview.TotalCost += summary.TotalCost + overview.InputTokens += summary.InputTokens + overview.OutputTokens += summary.OutputTokens + overview.TotalTokens += summary.InputTokens + summary.OutputTokens + overview.TotalDuration += summary.Duration + overview.TotalToolCalls += summary.ToolCalls + + switch status { + case StatusCompleted: + overview.Completed++ + case StatusFailed: + overview.Failed++ + case StatusAbandoned: + overview.Abandoned++ + } + + for model, cost := range modelCosts { + aggregate := overview.CostByModel[model] + aggregate.Model = model + aggregate.InputTokens += cost.InputTokens + aggregate.OutputTokens += cost.OutputTokens + aggregate.InputCost += cost.InputCost + aggregate.OutputCost += cost.OutputCost + aggregate.TotalCost += cost.TotalCost + aggregate.SessionCount += cost.SessionCount + overview.CostByModel[model] = aggregate + } + } + + if total := len(overview.Sessions); total > 0 { + overview.SuccessRate = float64(overview.Completed) / float64(total) + } + + sortSessions(overview.Sessions, filters.Sort) + + return overview, nil +} + +func (q *Query) SessionDetail(ctx context.Context, sessionID string) (*SessionDetail, error) { + leaf, err := q.client.Node.Get(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("get session: %w", err) + } + + nodes, err := q.loadAncestry(ctx, leaf) + if err != nil { + return nil, err + } + + summary, _, _, err := q.buildSessionSummaryFromNodes(nodes) + if err != nil { + return nil, err + } + + detail := &SessionDetail{ + Summary: summary, + Messages: make([]SessionMessage, 0, len(nodes)), + ToolFrequency: map[string]int{}, + } + + var lastTime time.Time + for i, node := range nodes { + blocks, _ := parseContentBlocks(node.Content) + inputTokens, outputTokens, totalTokens := tokenCounts(node) + inputCost, outputCost, totalCost := q.costForNode(node, inputTokens, outputTokens) + + toolCalls := extractToolCalls(blocks) + for _, tool := range toolCalls { + detail.ToolFrequency[tool]++ + } + + text := extractText(blocks) + delta := time.Duration(0) + if i > 0 { + delta = node.CreatedAt.Sub(lastTime) + } + lastTime = node.CreatedAt + + detail.Messages = append(detail.Messages, SessionMessage{ + Hash: node.ID, + Role: node.Role, + Model: node.Model, + Timestamp: node.CreatedAt, + Delta: delta, + InputTokens: inputTokens, + OutputTokens: outputTokens, + TotalTokens: totalTokens, + InputCost: inputCost, + OutputCost: outputCost, + TotalCost: totalCost, + ToolCalls: toolCalls, + Text: text, + }) + } + + return detail, nil +} + +func (q *Query) buildSessionSummary(ctx context.Context, leaf *ent.Node) (SessionSummary, map[string]ModelCost, string, error) { + nodes, err := q.loadAncestry(ctx, leaf) + if err != nil { + return SessionSummary{}, nil, "", err + } + + summary, modelCosts, status, err := q.buildSessionSummaryFromNodes(nodes) + if err != nil { + return SessionSummary{}, nil, "", err + } + + return summary, modelCosts, status, nil +} + +func (q *Query) buildSessionSummaryFromNodes(nodes []*ent.Node) (SessionSummary, map[string]ModelCost, string, error) { + if len(nodes) == 0 { + return SessionSummary{}, nil, "", fmt.Errorf("empty session nodes") + } + + start := nodes[0].CreatedAt + end := nodes[len(nodes)-1].CreatedAt + duration := end.Sub(start) + if duration < 0 { + duration = 0 + } + + label := buildLabel(nodes) + toolCalls := 0 + modelCosts := map[string]ModelCost{} + inputTokens := int64(0) + outputTokens := int64(0) + + hasToolError := false + for _, node := range nodes { + blocks, _ := parseContentBlocks(node.Content) + toolCalls += countToolCalls(blocks) + if blocksHaveToolError(blocks) { + hasToolError = true + } + + nodeInput, nodeOutput, _ := tokenCounts(node) + inputTokens += nodeInput + outputTokens += nodeOutput + + model := normalizeModel(node.Model) + if model == "" { + continue + } + + pricing, ok := PricingForModel(q.pricing, model) + if !ok { + continue + } + + inputCost, outputCost, totalCost := CostForTokens(pricing, nodeInput, nodeOutput) + current := modelCosts[model] + current.Model = model + current.InputTokens += nodeInput + current.OutputTokens += nodeOutput + current.InputCost += inputCost + current.OutputCost += outputCost + current.TotalCost += totalCost + current.SessionCount = 1 + modelCosts[model] = current + } + + model := dominantModel(modelCosts) + if model == "" { + model = firstModel(nodes) + } + inputCost, outputCost, totalCost := sumModelCosts(modelCosts) + + status := determineStatus(nodes[len(nodes)-1], hasToolError) + + summary := SessionSummary{ + ID: nodes[len(nodes)-1].ID, + Label: label, + Model: model, + Status: status, + StartTime: start, + EndTime: end, + Duration: duration, + InputTokens: inputTokens, + OutputTokens: outputTokens, + InputCost: inputCost, + OutputCost: outputCost, + TotalCost: totalCost, + ToolCalls: toolCalls, + MessageCount: len(nodes), + } + + return summary, modelCosts, status, nil +} + +func (q *Query) loadAncestry(ctx context.Context, leaf *ent.Node) ([]*ent.Node, error) { + nodes := []*ent.Node{} + current := leaf + for current != nil { + nodes = append(nodes, current) + parent, err := current.QueryParent().Only(ctx) + if ent.IsNotFound(err) { + break + } + if err != nil { + return nil, fmt.Errorf("query parent: %w", err) + } + current = parent + } + + for i, j := 0, len(nodes)-1; i < j; i, j = i+1, j-1 { + nodes[i], nodes[j] = nodes[j], nodes[i] + } + + return nodes, nil +} + +func (q *Query) costForNode(node *ent.Node, inputTokens, outputTokens int64) (float64, float64, float64) { + model := normalizeModel(node.Model) + if model == "" { + return 0, 0, 0 + } + + pricing, ok := PricingForModel(q.pricing, model) + if !ok { + return 0, 0, 0 + } + + return CostForTokens(pricing, inputTokens, outputTokens) +} + +func tokenCounts(node *ent.Node) (int64, int64, int64) { + inputTokens := int64(0) + outputTokens := int64(0) + totalTokens := int64(0) + if node.PromptTokens != nil { + inputTokens = int64(*node.PromptTokens) + } + if node.CompletionTokens != nil { + outputTokens = int64(*node.CompletionTokens) + } + if node.TotalTokens != nil { + totalTokens = int64(*node.TotalTokens) + } else { + totalTokens = inputTokens + outputTokens + } + + return inputTokens, outputTokens, totalTokens +} + +func parseContentBlocks(raw []map[string]any) ([]llm.ContentBlock, error) { + if len(raw) == 0 { + return nil, nil + } + + data, err := json.Marshal(raw) + if err != nil { + return nil, err + } + + var blocks []llm.ContentBlock + if err := json.Unmarshal(data, &blocks); err != nil { + return nil, err + } + + return blocks, nil +} + +func extractToolCalls(blocks []llm.ContentBlock) []string { + tools := []string{} + for _, block := range blocks { + if block.Type == "tool_use" && block.ToolName != "" { + tools = append(tools, block.ToolName) + } + } + return tools +} + +func countToolCalls(blocks []llm.ContentBlock) int { + count := 0 + for _, block := range blocks { + if block.Type == "tool_use" { + count++ + } + } + return count +} + +func blocksHaveToolError(blocks []llm.ContentBlock) bool { + for _, block := range blocks { + if block.Type == "tool_result" && block.IsError { + return true + } + } + return false +} + +func extractText(blocks []llm.ContentBlock) string { + texts := []string{} + for _, block := range blocks { + switch { + case block.Text != "": + texts = append(texts, block.Text) + case block.ToolOutput != "": + texts = append(texts, block.ToolOutput) + case block.ToolName != "": + texts = append(texts, fmt.Sprintf("tool call: %s", block.ToolName)) + } + } + return strings.Join(texts, "\n") +} + +func buildLabel(nodes []*ent.Node) string { + for _, node := range nodes { + if node.Role != "user" { + continue + } + blocks, _ := parseContentBlocks(node.Content) + text := strings.TrimSpace(extractText(blocks)) + if text == "" { + continue + } + text = strings.Split(text, "\n")[0] + return truncate(text, 24) + } + + return truncate(nodes[len(nodes)-1].ID, 12) +} + +func truncate(value string, limit int) string { + if len(value) <= limit { + return value + } + if limit <= 3 { + return value[:limit] + } + return value[:limit-3] + "..." +} + +func dominantModel(costs map[string]ModelCost) string { + var model string + maxCost := float64(0) + for name, cost := range costs { + if cost.TotalCost > maxCost { + maxCost = cost.TotalCost + model = name + } + } + return model +} + +func firstModel(nodes []*ent.Node) string { + for _, node := range nodes { + if node.Model != "" { + return normalizeModel(node.Model) + } + } + return "" +} + +func sumModelCosts(costs map[string]ModelCost) (float64, float64, float64) { + inputCost := 0.0 + outputCost := 0.0 + totalCost := 0.0 + for _, cost := range costs { + inputCost += cost.InputCost + outputCost += cost.OutputCost + totalCost += cost.TotalCost + } + return inputCost, outputCost, totalCost +} + +func matchesFilters(summary SessionSummary, filters Filters) bool { + if filters.Model != "" { + if normalizeModel(summary.Model) != normalizeModel(filters.Model) { + return false + } + } + if filters.Status != "" && summary.Status != filters.Status { + return false + } + if filters.From != nil && summary.EndTime.Before(*filters.From) { + return false + } + if filters.To != nil && summary.StartTime.After(*filters.To) { + return false + } + if filters.Since > 0 { + cutoff := time.Now().Add(-filters.Since) + if summary.EndTime.Before(cutoff) { + return false + } + } + return true +} + +func sortSessions(sessions []SessionSummary, sortKey string) { + switch sortKey { + case "time": + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].EndTime.After(sessions[j].EndTime) + }) + case "tokens": + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].InputTokens+sessions[i].OutputTokens > sessions[j].InputTokens+sessions[j].OutputTokens + }) + case "duration": + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].Duration > sessions[j].Duration + }) + default: + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].TotalCost > sessions[j].TotalCost + }) + } +} + +func determineStatus(leaf *ent.Node, hasToolError bool) string { + if hasToolError { + return StatusFailed + } + + role := strings.ToLower(leaf.Role) + if role != "assistant" { + return StatusAbandoned + } + + reason := strings.ToLower(strings.TrimSpace(leaf.StopReason)) + switch reason { + case "stop", "end_turn", "end-turn", "eos": + return StatusCompleted + case "length", "max_tokens", "content_filter", "tool_use", "tool_use_response": + return StatusFailed + case "": + return StatusUnknown + default: + if strings.Contains(reason, "error") { + return StatusFailed + } + } + + return StatusUnknown +} diff --git a/pkg/deck/types.go b/pkg/deck/types.go new file mode 100644 index 0000000..592cd6a --- /dev/null +++ b/pkg/deck/types.go @@ -0,0 +1,89 @@ +package deck + +import "time" + +type Pricing struct { + Input float64 `json:"input"` + Output float64 `json:"output"` +} + +type SessionSummary struct { + ID string `json:"id"` + Label string `json:"label"` + Model string `json:"model"` + Status string `json:"status"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Duration time.Duration `json:"duration_ns"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + InputCost float64 `json:"input_cost"` + OutputCost float64 `json:"output_cost"` + TotalCost float64 `json:"total_cost"` + ToolCalls int `json:"tool_calls"` + MessageCount int `json:"message_count"` +} + +type SessionMessage struct { + Hash string `json:"hash"` + Role string `json:"role"` + Model string `json:"model"` + Timestamp time.Time `json:"timestamp"` + Delta time.Duration `json:"delta_ns"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + InputCost float64 `json:"input_cost"` + OutputCost float64 `json:"output_cost"` + TotalCost float64 `json:"total_cost"` + ToolCalls []string `json:"tool_calls"` + Text string `json:"text"` +} + +type SessionDetail struct { + Summary SessionSummary `json:"summary"` + Messages []SessionMessage `json:"messages"` + ToolFrequency map[string]int `json:"tool_frequency"` +} + +type ModelCost struct { + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + InputCost float64 `json:"input_cost"` + OutputCost float64 `json:"output_cost"` + TotalCost float64 `json:"total_cost"` + SessionCount int `json:"session_count"` +} + +type DeckOverview struct { + Sessions []SessionSummary `json:"sessions"` + TotalCost float64 `json:"total_cost"` + TotalTokens int64 `json:"total_tokens"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalDuration time.Duration `json:"total_duration_ns"` + TotalToolCalls int `json:"total_tool_calls"` + SuccessRate float64 `json:"success_rate"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Abandoned int `json:"abandoned"` + CostByModel map[string]ModelCost `json:"cost_by_model"` +} + +type Filters struct { + Since time.Duration + From *time.Time + To *time.Time + Model string + Status string + Sort string + Session string +} + +const ( + StatusCompleted = "completed" + StatusFailed = "failed" + StatusAbandoned = "abandoned" + StatusUnknown = "unknown" +) diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go index dafba33..6e3fffc 100644 --- a/proxy/proxy_test.go +++ b/proxy/proxy_test.go @@ -308,7 +308,6 @@ var _ = Describe("Non-Streaming Proxy", func() { It("filters Accept-Encoding header to let Go handle compression", func() { // Reset receivedHeaders to ensure we're checking fresh data receivedHeaders = make(http.Header) - reqBody := makeOllamaRequestBody("test-model", []ollamaTestMessage{ {Role: "user", Content: "hello"}, }, boolPtr(false)) diff --git a/web/deck/deck.css b/web/deck/deck.css new file mode 100644 index 0000000..f9c1851 --- /dev/null +++ b/web/deck/deck.css @@ -0,0 +1,246 @@ +:root { + color-scheme: dark; + --bg: #0b0b0b; + --panel: #121212; + --panel-border: #1f1f1f; + --text: #f5efe3; + --muted: #9e9a92; + --accent: #e38b55; + --accent-2: #f3b87a; + --good: #6cc48a; + --bad: #e26d5a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "JetBrains Mono", "Fira Code", "SFMono-Regular", ui-monospace, monospace; + background: radial-gradient(circle at top, #1c120a, var(--bg)); + color: var(--text); +} + +.app { + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +.app__header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--panel-border); + padding-bottom: 16px; + margin-bottom: 24px; +} + +.app__title { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--accent); +} + +.app__subtitle { + color: var(--muted); + font-size: 12px; + margin-top: 6px; +} + +.app__badge { + border: 1px solid var(--panel-border); + border-radius: 999px; + padding: 6px 12px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.2em; +} + +.metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.metric { + background: var(--panel); + border: 1px solid var(--panel-border); + padding: 16px; + border-radius: 12px; +} + +.metric__label { + color: var(--muted); + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.12em; +} + +.metric__value { + font-size: 20px; + font-weight: 600; + margin-top: 10px; +} + +.metric__sub { + color: var(--muted); + font-size: 12px; + margin-top: 6px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 12px; + padding: 16px; +} + +.panel__title { + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.18em; + color: var(--muted); + margin-bottom: 16px; +} + +.model-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + align-items: center; + margin-bottom: 10px; +} + +.model-row__name { + font-size: 13px; +} + +.model-row__bar { + height: 8px; + border-radius: 999px; + background: #1f1b16; + overflow: hidden; + position: relative; + margin-top: 6px; +} + +.model-row__fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); +} + +.table { + display: grid; + gap: 8px; +} + +.table__row { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr 1fr auto; + gap: 8px; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; +} + +.table__row:hover { + border-color: var(--accent); + background: rgba(227, 139, 85, 0.08); +} + +.table__header { + color: var(--muted); + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.12em; +} + +.status { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.status--completed { + color: var(--good); +} + +.status--failed { + color: var(--bad); +} + +.status--abandoned { + color: var(--muted); +} + +.detail { + min-height: 220px; +} + +.detail__empty { + color: var(--muted); + font-size: 13px; +} + +.detail__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.detail__meta-item { + font-size: 13px; + color: var(--muted); +} + +.detail__meta-item span { + color: var(--text); + display: block; + margin-top: 6px; + font-size: 16px; + font-weight: 600; +} + +.timeline { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.timeline__row { + display: grid; + grid-template-columns: 80px 1fr 120px 120px; + gap: 12px; + font-size: 12px; + padding: 8px 10px; + border-radius: 8px; + background: #111; +} + +.timeline__row span { + color: var(--muted); +} + +@media (max-width: 900px) { + .table__row { + grid-template-columns: 1fr 1fr; + grid-auto-rows: auto; + } + .timeline__row { + grid-template-columns: 1fr 1fr; + } +} diff --git a/web/deck/deck.js b/web/deck/deck.js new file mode 100644 index 0000000..ad8ef05 --- /dev/null +++ b/web/deck/deck.js @@ -0,0 +1,145 @@ +const metricsEl = document.getElementById("metrics"); +const sessionsEl = document.getElementById("sessions"); +const modelsEl = document.getElementById("models"); +const detailEl = document.getElementById("detail"); +const sessionCountEl = document.getElementById("session-count"); + +const formatCost = (value) => `$${value.toFixed(3)}`; +const formatTokens = (value) => { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return `${value}`; +}; +const formatDuration = (valueNs) => { + const seconds = Math.floor(valueNs / 1e9); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const min = minutes % 60; + const sec = seconds % 60; + if (hours > 0) return `${hours}h ${min}m`; + if (minutes > 0) return `${min}m ${sec}s`; + return `${sec}s`; +}; + +const statusClass = (status) => { + if (status === "completed") return "status--completed"; + if (status === "failed") return "status--failed"; + return "status--abandoned"; +}; + +const renderMetrics = (data) => { + metricsEl.innerHTML = ""; + const items = [ + { label: "total spend", value: formatCost(data.total_cost), sub: `avg ${formatCost(data.total_cost / Math.max(data.sessions.length, 1))}` }, + { label: "tokens used", value: formatTokens(data.total_tokens), sub: `${formatTokens(data.input_tokens)} in / ${formatTokens(data.output_tokens)} out` }, + { label: "agent time", value: formatDuration(data.total_duration_ns), sub: `${formatDuration(data.total_duration_ns / Math.max(data.sessions.length, 1))} avg` }, + { label: "tool calls", value: data.total_tool_calls, sub: `${(data.total_tool_calls / Math.max(data.sessions.length, 1)).toFixed(1)} avg` }, + { label: "success rate", value: `${Math.round(data.success_rate * 100)}%`, sub: `${data.completed}/${data.sessions.length}` }, + ]; + + items.forEach((item) => { + const card = document.createElement("div"); + card.className = "metric"; + card.innerHTML = ` +
${item.label}
+
${item.value}
+
${item.sub}
+ `; + metricsEl.appendChild(card); + }); +}; + +const renderModels = (data) => { + modelsEl.innerHTML = ""; + const models = Object.values(data.cost_by_model || {}).sort((a, b) => b.total_cost - a.total_cost); + if (models.length === 0) { + modelsEl.textContent = "no model cost data"; + return; + } + + const max = models[0].total_cost || 1; + models.forEach((model) => { + const row = document.createElement("div"); + row.className = "model-row"; + const percent = Math.round((model.total_cost / max) * 100); + row.innerHTML = ` +
+
${model.model}
+
+
+
${formatCost(model.total_cost)}
+ `; + modelsEl.appendChild(row); + }); +}; + +const renderSessions = (data) => { + sessionsEl.innerHTML = ""; + const header = document.createElement("div"); + header.className = "table__row table__header"; + header.innerHTML = "
label
model
duration
cost
status
"; + sessionsEl.appendChild(header); + + data.sessions.forEach((session) => { + const row = document.createElement("div"); + row.className = "table__row"; + row.innerHTML = ` +
${session.label}
+
${session.model || "unknown"}
+
${formatDuration(session.duration_ns)}
+
${formatCost(session.total_cost)}
+
${session.status}
+ `; + row.addEventListener("click", () => loadSession(session.id)); + sessionsEl.appendChild(row); + }); +}; + +const renderSessionDetail = (detail) => { + detailEl.innerHTML = ""; + const meta = document.createElement("div"); + meta.className = "detail__meta"; + meta.innerHTML = ` +
model${detail.summary.model || "unknown"}
+
duration${formatDuration(detail.summary.duration_ns)}
+
input cost${formatCost(detail.summary.input_cost)}
+
output cost${formatCost(detail.summary.output_cost)}
+ `; + + const timeline = document.createElement("div"); + timeline.className = "timeline"; + detail.messages.forEach((msg) => { + const row = document.createElement("div"); + row.className = "timeline__row"; + row.innerHTML = ` +
${new Date(msg.timestamp).toLocaleTimeString()}
+
${msg.role}
+
${formatTokens(msg.total_tokens)} tok
+
${formatCost(msg.total_cost)}
+ `; + timeline.appendChild(row); + }); + + detailEl.appendChild(meta); + detailEl.appendChild(timeline); +}; + +const loadOverview = async () => { + const res = await fetch("/api/overview"); + const data = await res.json(); + sessionCountEl.textContent = `${data.sessions.length} sessions`; + renderMetrics(data); + renderModels(data); + renderSessions(data); +}; + +const loadSession = async (sessionId) => { + const res = await fetch(`/api/session/${sessionId}`); + const data = await res.json(); + renderSessionDetail(data); +}; + +loadOverview().catch((err) => { + sessionCountEl.textContent = "failed to load data"; + console.error(err); +}); diff --git a/web/deck/embed.go b/web/deck/embed.go new file mode 100644 index 0000000..c7348e7 --- /dev/null +++ b/web/deck/embed.go @@ -0,0 +1,6 @@ +package deckweb + +import "embed" + +//go:embed index.html deck.js deck.css +var FS embed.FS diff --git a/web/deck/index.html b/web/deck/index.html new file mode 100644 index 0000000..5e20b41 --- /dev/null +++ b/web/deck/index.html @@ -0,0 +1,40 @@ + + + + + + tapes deck + + + +
+
+
+
tapes deck
+
loading sessions...
+
+
local only
+
+ +
+ +
+
+
cost by model
+
+
+
+
sessions
+
+
+
+ +
+
session detail
+
select a session to drill down
+
+
+ + + + From 023eee892cddc75905fdbefc85b1ec86c1ddb9e2 Mon Sep 17 00:00:00 2001 From: Brian 'bdougie' Douglas Date: Sun, 1 Feb 2026 14:46:56 -0800 Subject: [PATCH 2/2] feat: default deck to home database Prefer user and XDG database paths plus env overrides, with tests for lookup order. --- cmd/tapes/deck/deck.go | 18 ++++++++- cmd/tapes/deck/deck_test.go | 74 +++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 cmd/tapes/deck/deck_test.go diff --git a/cmd/tapes/deck/deck.go b/cmd/tapes/deck/deck.go index 38195e0..955b5b0 100644 --- a/cmd/tapes/deck/deck.go +++ b/cmd/tapes/deck/deck.go @@ -159,6 +159,13 @@ func resolveSQLitePath(override string) (string, error) { return override, nil } + if envPath := strings.TrimSpace(os.Getenv("TAPES_SQLITE")); envPath != "" { + return envPath, nil + } + if envPath := strings.TrimSpace(os.Getenv("TAPES_DB")); envPath != "" { + return envPath, nil + } + candidates := []string{ "tapes.db", "tapes.sqlite", @@ -168,10 +175,17 @@ func resolveSQLitePath(override string) (string, error) { home, err := os.UserHomeDir() if err == nil { - candidates = append(candidates, + candidates = append([]string{ filepath.Join(home, ".tapes", "tapes.db"), filepath.Join(home, ".tapes", "tapes.sqlite"), - ) + }, candidates...) + } + + if xdgHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); xdgHome != "" { + candidates = append([]string{ + filepath.Join(xdgHome, "tapes", "tapes.db"), + filepath.Join(xdgHome, "tapes", "tapes.sqlite"), + }, candidates...) } for _, candidate := range candidates { diff --git a/cmd/tapes/deck/deck_test.go b/cmd/tapes/deck/deck_test.go new file mode 100644 index 0000000..461d6d5 --- /dev/null +++ b/cmd/tapes/deck/deck_test.go @@ -0,0 +1,74 @@ +package deckcmder + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resolveSQLitePath", func() { + var ( + origHome string + origXDG string + origTapesDB string + origTapesSQ string + origCwd string + ) + + BeforeEach(func() { + origHome = os.Getenv("HOME") + origXDG = os.Getenv("XDG_DATA_HOME") + origTapesDB = os.Getenv("TAPES_DB") + origTapesSQ = os.Getenv("TAPES_SQLITE") + var err error + origCwd, err = os.Getwd() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + Expect(os.Setenv("HOME", origHome)).To(Succeed()) + Expect(os.Setenv("XDG_DATA_HOME", origXDG)).To(Succeed()) + Expect(os.Setenv("TAPES_DB", origTapesDB)).To(Succeed()) + Expect(os.Setenv("TAPES_SQLITE", origTapesSQ)).To(Succeed()) + Expect(os.Chdir(origCwd)).To(Succeed()) + }) + + It("prefers TAPES_SQLITE when set", func() { + Expect(os.Setenv("TAPES_SQLITE", "/tmp/custom.db")).To(Succeed()) + Expect(os.Setenv("TAPES_DB", "")).To(Succeed()) + + path, err := resolveSQLitePath("") + Expect(err).NotTo(HaveOccurred()) + Expect(path).To(Equal("/tmp/custom.db")) + }) + + It("resolves ~/.tapes/tapes.db when present", func() { + homeDir, err := os.MkdirTemp("", "tapes-home-*") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + _ = os.RemoveAll(homeDir) + }) + + tmpDir, err := os.MkdirTemp("", "tapes-cwd-*") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { + _ = os.RemoveAll(tmpDir) + }) + + Expect(os.Setenv("HOME", homeDir)).To(Succeed()) + Expect(os.Setenv("XDG_DATA_HOME", "")).To(Succeed()) + Expect(os.Setenv("TAPES_DB", "")).To(Succeed()) + Expect(os.Setenv("TAPES_SQLITE", "")).To(Succeed()) + Expect(os.Chdir(tmpDir)).To(Succeed()) + + dbPath := filepath.Join(homeDir, ".tapes", "tapes.db") + Expect(os.MkdirAll(filepath.Dir(dbPath), 0o755)).To(Succeed()) + Expect(os.WriteFile(dbPath, []byte("test"), 0o644)).To(Succeed()) + + path, err := resolveSQLitePath("") + Expect(err).NotTo(HaveOccurred()) + Expect(path).To(Equal(dbPath)) + }) +})