Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0212bb6
feat(recap): introduce entire recap command and package
peyton-alt Apr 23, 2026
478c13a
feat(recap): wire CLI to /api/v1/me/recap endpoint
peyton-alt Apr 23, 2026
5f57126
feat(recap): remove --30 flag and Range30d enum
peyton-alt Apr 23, 2026
5fd9e1e
fix(recap): include recap_view.go in Range30d removal (pre-commit hoo…
peyton-alt Apr 23, 2026
3d5f2ae
feat(recap): add team style + renderComparisonBar primitive
peyton-alt Apr 23, 2026
3930872
fix(recap): restore barMinWidth=12 per spec
peyton-alt Apr 23, 2026
801ac4a
feat(recap): rewrite summary panel (you/team/top/context)
peyton-alt Apr 23, 2026
8d0ddcf
feat(recap): pad activity strip + add peak annotation
peyton-alt Apr 23, 2026
c990a5e
feat(recap): rewrite agents card with bars + view toggle
peyton-alt Apr 23, 2026
1e46c9a
feat(recap): drop bottom panel (worktrees · labels · repos)
peyton-alt Apr 23, 2026
2d70a05
feat(recap): add v keybinding to cycle view mode in TUI
peyton-alt Apr 23, 2026
8ee3b5d
feat(recap): readout-only fallback for narrow terminals
peyton-alt Apr 23, 2026
5f612a6
fix(recap): session-level tokens, dedupe title, weight top-agent by c…
peyton-alt Apr 23, 2026
56cef54
refactor(recap): drop legacy contributors fallback now that /me/recap…
peyton-alt Apr 23, 2026
b15d9d4
fix(recap): match mockup — wire team qualitative rows + label dots
peyton-alt Apr 23, 2026
2f70929
refactor(recap): drop dead code + register recap in root.go
peyton-alt Apr 23, 2026
d38955c
Merge remote-tracking branch 'origin/main' into entire-activity
peyton-alt Apr 27, 2026
5f17760
feat(recap): wire me-side server data through view + TUI
peyton-alt Apr 27, 2026
88e5d2d
fix(recap): wrap TUI in viewport so output scrolls instead of clipping
peyton-alt Apr 28, 2026
a4e83ca
perf(recap): prefetch all ranges in parallel + clear stale data on miss
peyton-alt Apr 28, 2026
bc363f6
style(recap): gofmt tui.go field alignment
peyton-alt Apr 28, 2026
8e87f76
Refactor recap to server-backed static renderer
peyton-alt May 4, 2026
e2b6f5c
Allow intentional interface returns
peyton-alt May 4, 2026
a334175
Color recap labels and skills
peyton-alt May 4, 2026
03eba31
Merge remote-tracking branch 'origin/main' into entire-activity
peyton-alt May 4, 2026
885346c
Fix concurrent session state saves
peyton-alt May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ linters:
- github.com/go-git/go-git/v6/plumbing.EncodedObject
- github.com/go-git/go-git/v6/storage.Storer
- github.com/go-git/go-git/v6/plumbing/storer.EncodedObjectIter
- github.com/go-git/go-git/v6/x/plugin.Signer
- golang.org/x/crypto/ssh/agent.Agent
- github.com/entireio/cli/cmd/entire/cli/summarize.Generator
- github.com/entireio/cli/e2e/agents.Session
- github.com/go-git/go-billy/v6.Filesystem
- golang.org/x/crypto/ssh/agent.Agent
Expand Down
159 changes: 159 additions & 0 deletions cmd/entire/cli/recap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package cli

import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/entireio/cli/cmd/entire/cli/gitremote"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/recap"
)

type recapFlags struct {
day, week, month, d90 bool
view string
agent string
color string
insecureHTTP bool
}

const (
recapColorAuto = "auto"
recapColorAlways = "always"
recapColorNever = "never"
)

func newRecapCmd() *cobra.Command {
f := &recapFlags{view: string(recap.ViewBoth), agent: recap.AgentAll, color: recapColorAuto}
cmd := &cobra.Command{
Use: "recap",
Short: "Summarize recent checkpoint activity",
RunE: func(cmd *cobra.Command, _ []string) error {
return runRecap(cmd.Context(), cmd.OutOrStdout(), f)
},
}
cmd.Flags().BoolVar(&f.day, "day", false, "Today only (default)")
cmd.Flags().BoolVar(&f.week, "week", false, "Last 7 days")
cmd.Flags().BoolVar(&f.month, "month", false, "This calendar month")
cmd.Flags().BoolVar(&f.d90, "90", false, "Rolling 90 days")
cmd.Flags().StringVar(&f.agent, "agent", recap.AgentAll, "Agent id to show, or all")
cmd.Flags().StringVar(&f.view, "view", string(recap.ViewBoth), "Which columns to show: you, team, or both")
cmd.Flags().StringVar(&f.color, "color", recapColorAuto, "Color output: auto, always, or never")
cmd.Flags().BoolVar(&f.insecureHTTP, "insecure-http-auth", false, "Allow plain-HTTP auth (local dev only)")
cmd.MarkFlagsMutuallyExclusive("day", "week", "month", "90")
return cmd
}

func (f *recapFlags) rangeKey() recap.RangeKey {
switch {
case f.week:
return recap.RangeWeek
case f.month:
return recap.RangeMonth
case f.d90:
return recap.Range90d
default:
return recap.RangeDay
}
}

func (f *recapFlags) mode() recap.ViewMode {
switch strings.ToLower(strings.TrimSpace(f.view)) {
case "you", "me":
return recap.ViewYou
case "team", "contributors":
return recap.ViewTeam
case "both", "":
return recap.ViewBoth
default:
return recap.ViewMode(f.view)
}
}

func (f *recapFlags) agentName() string {
agent := strings.ToLower(strings.TrimSpace(f.agent))
if agent == "" {
return recap.AgentAll
}
return agent
}

func (f *recapFlags) colorEnabled(w io.Writer) (bool, error) {
switch strings.ToLower(strings.TrimSpace(f.color)) {
case "", recapColorAuto:
return shouldUseColor(w) && !IsAccessibleMode(), nil
case recapColorAlways:
return true, nil
case recapColorNever:
return false, nil
default:
return false, fmt.Errorf("invalid --color %q (use auto, always, or never)", f.color)
}
}

func runRecap(ctx context.Context, w io.Writer, f *recapFlags) error {
if _, err := paths.WorktreeRoot(ctx); err != nil {
fmt.Fprintln(w, "Not a git repository. Run 'entire recap' from within a git repository.")
return NewSilentError(errors.New("not a git repository"))
}
mode := f.mode()
if !mode.Valid() {
return fmt.Errorf("invalid --view %q (use you, team, or both)", f.view)
}
color, err := f.colorEnabled(w)
if err != nil {
return err
}
client, err := NewAuthenticatedAPIClient(f.insecureHTTP)
if err != nil {
fmt.Fprintln(w, "Sign in with `entire login` to use `entire recap`.")
return NewSilentError(err)
}
rangeKey := f.rangeKey()
start, end := rangeKey.Bounds(time.Now())
resp, err := recap.FetchMeRecap(ctx, client, start, end, currentRepoSlug(ctx), 0)
if err != nil {
return fmt.Errorf("fetch recap: %w", err)
}
fmt.Fprint(w, recap.RenderStaticRecap(resp, recap.RenderOptions{
Range: rangeKey,
View: mode,
Agent: f.agentName(),
Width: terminalWidth(w),
Color: color,
}))
fmt.Fprintln(w)
return nil
}

func terminalWidth(w io.Writer) int {
file, ok := w.(*os.File)
if !ok {
return recap.DefaultWidth
}
if !isatty.IsTerminal(file.Fd()) {
return recap.DefaultWidth
}
width, _, err := term.GetSize(int(file.Fd())) //nolint:gosec // fd values fit in int on supported platforms
if err != nil || width <= 0 {
return recap.DefaultWidth
}
return width
}

func currentRepoSlug(ctx context.Context) string {
_, owner, repoName, err := gitremote.ResolveRemoteRepo(ctx, "origin")
if err != nil || owner == "" || repoName == "" {
return ""
}
return owner + "/" + repoName
}
3 changes: 3 additions & 0 deletions cmd/entire/cli/recap/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package recap contains the server-backed data types and static renderer
// behind `entire recap`.
package recap
138 changes: 138 additions & 0 deletions cmd/entire/cli/recap/me_recap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package recap

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"time"

"github.com/entireio/cli/cmd/entire/cli/api"
)

// MeRecapResponse mirrors GET /api/v1/me/recap.
type MeRecapResponse struct {
Timeframe string `json:"timeframe"`
Repo *string `json:"repo"`
Since string `json:"since"`
Until string `json:"until"`
Agents map[string]AgentEntry `json:"agents"`
Summary Summary `json:"summary"`
Contributors *ContribSummary `json:"contributors"`
Daily []DailyCount `json:"daily"`
UpdatedAt string `json:"updated_at"`
}

// Summary contains top-level counts intended for CLI rendering.
type Summary struct {
Me SummaryTotals `json:"me"`
Team *SummaryTotals `json:"team"`
RepoCount int `json:"repoCount"`
ActiveDays int `json:"activeDays"`
Analysis AnalysisStatus `json:"analysis"`
}

type SummaryTotals struct {
Sessions int `json:"sessions"`
Checkpoints int `json:"checkpoints"`
Tokens int `json:"tokens"`
}

type AnalysisStatus struct {
Complete int `json:"complete"`
Pending int `json:"pending"`
Failed int `json:"failed"`
}

type DailyCount struct {
Date string `json:"date"`
Count int `json:"count"`
}

type AgentEntry struct {
AgentID string `json:"agentId"`
AgentLabel string `json:"agentLabel"`
Me AgentAggregate `json:"me"`
Contributors *AgentAggregate `json:"contributors"`
}

type AgentAggregate struct {
Sessions int `json:"sessions"`
Checkpoints int `json:"checkpoints"`
Tokens int `json:"tokens"`
TranscriptTokens int `json:"transcriptTokens"`
FilesChanged int `json:"filesChanged"`
Labels []LabelCount `json:"labels"`
Skills []SkillCount `json:"skills"`
MCPServers []McpCount `json:"mcpServers"`
ToolMix ToolMix `json:"toolMix"`
}

type LabelCount struct {
Label string `json:"label"`
Count int `json:"count"`
}

type SkillCount struct {
Skill string `json:"skill"`
Count int `json:"count"`
}

type McpCount struct {
Name string `json:"name"`
Count int `json:"count"`
}

type ToolMix struct {
Shell int `json:"shell"`
FileOps int `json:"fileOps"`
Search int `json:"search"`
MCP int `json:"mcp"`
Agent int `json:"agent"`
Other int `json:"other"`
}

type ContribSummary struct {
DistinctUsers int `json:"distinctUsers"`
TotalTokens int `json:"totalTokens"`
TotalCheckpoints int `json:"totalCheckpoints"`
}

// FetchMeRecap fetches one server-backed recap window.
func FetchMeRecap(
ctx context.Context,
client *api.Client,
since, until time.Time,
repo string,
limit int,
) (*MeRecapResponse, error) {
if client == nil {
return nil, errors.New("me/recap: nil client")
}
q := url.Values{}
q.Set("since", since.UTC().Format(time.RFC3339))
q.Set("until", until.UTC().Format(time.RFC3339))
if repo != "" {
q.Set("repo", repo)
}
if limit > 0 {
q.Set("limit", strconv.Itoa(limit))
}
resp, err := client.Get(ctx, "/api/v1/me/recap?"+q.Encode())
if err != nil {
return nil, fmt.Errorf("me/recap get: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body) //nolint:errcheck // best-effort error body
return nil, fmt.Errorf("me/recap: http %d: %s", resp.StatusCode, string(body))
}
var out MeRecapResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("me/recap decode: %w", err)
}
return &out, nil
}
62 changes: 62 additions & 0 deletions cmd/entire/cli/recap/model.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package recap

import "time"

// RangeKey names the static recap windows supported by `entire recap`.
type RangeKey string

const (
RangeDay RangeKey = "day"
RangeWeek RangeKey = "week"
RangeMonth RangeKey = "month"
Range90d RangeKey = "90d"
)

// Title returns the panel title for a range.
func (r RangeKey) Title() string {
switch r {
case RangeDay:
return "Today"
case RangeWeek:
return "Last 7 days"
case RangeMonth:
return "This month"
case Range90d:
return "Last 90 days"
default:
return "Today"
}
}

// Bounds returns a half-open [start, end) window in the user's local time.
func (r RangeKey) Bounds(now time.Time) (time.Time, time.Time) {
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
switch r {
case RangeDay:
return dayStart, dayEnd
case RangeWeek:
return dayEnd.AddDate(0, 0, -7), dayEnd
case RangeMonth:
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
return monthStart, monthStart.AddDate(0, 1, 0)
case Range90d:
return dayEnd.AddDate(0, 0, -90), dayEnd
default:
return dayStart, dayEnd
}
}

// ViewMode selects which columns render in the static agents panel.
type ViewMode string

const (
ViewYou ViewMode = "you"
ViewTeam ViewMode = "team"
ViewBoth ViewMode = "both"
)

// Valid reports whether the mode is one of the supported static modes.
func (m ViewMode) Valid() bool {
return m == ViewYou || m == ViewTeam || m == ViewBoth
}
Loading
Loading