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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 110 additions & 40 deletions cmd/entire/cli/activity_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const (
dateUnknown = "unknown"
activityTimeframe = "last-month"
activityLimit = 1000
// sessionsOverviewLimit mirrors entire.io's USER_OVERVIEW_RECENT_SESSIONS_LIMIT
// so the CLI's recent-session list matches the web Overview page's window.
sessionsOverviewLimit = 50
)

// knownAgents maps normalized agent strings from the API to display IDs.
Expand All @@ -44,53 +47,73 @@ var knownAgents = map[string]string{
}

func newActivityCmd() *cobra.Command {
var showCommits bool
cmd := &cobra.Command{
Use: "activity",
Short: "Show your activity overview",
Long: "Display your activity overview, repository breakdown, and recent commits from entire.io",
Long: "Display your activity overview, repository breakdown, and recent sessions from entire.io.\n\n" +
"The recent list shows your sessions by default, matching the entire.io Overview page. " +
"Pass --commits to show recent commits instead.",
RunE: func(cmd *cobra.Command, _ []string) error {
return runActivity(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr())
return runActivity(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), showCommits)
},
}
cmd.Flags().BoolVar(&showCommits, "commits", false, "Show recent commits instead of recent sessions")
return cmd
}

func runActivity(ctx context.Context, w, errW io.Writer) error {
func runActivity(ctx context.Context, w, errW io.Writer, showCommits bool) error {
return runAuthenticatedActivityAPI(ctx, errW, false, func(ctx context.Context, client *api.Client) error {
// Non-interactive fallback: piped output or accessibility mode
if !interactive.IsTerminalWriter(w) || IsAccessibleMode() {
return runActivityStatic(ctx, w, client)
return runActivityStatic(ctx, w, client, showCommits)
}

return runActivityTUI(ctx, client)
return runActivityTUI(ctx, client, showCommits)
})
}

func runActivityStatic(ctx context.Context, w io.Writer, client *api.Client) error {
activity, commits, err := fetchActivityData(ctx, client)
func runActivityStatic(ctx context.Context, w io.Writer, client *api.Client, showCommits bool) error {
sty := newActivityStyles(w)

if showCommits {
activity, commits, err := fetchActivityWith(ctx, client, fetchCommits)
if err != nil {
return err
}
renderActivityHeader(w, sty, statsFromActivity(activity), activity.Repos, activity.HourlyContributions)
renderCommitList(w, sty, groupCommitsByDay(commits))
return nil
}

activity, sessions, err := fetchActivityWith(ctx, client, fetchSessions)
if err != nil {
return err
}
renderActivityHeader(w, sty, statsFromActivity(activity), activity.Repos, activity.HourlyContributions)
renderSessionList(w, sty, groupSessionsByDay(sessions))
return nil
}

stats := contributionStats{
// statsFromActivity projects the aggregated /me/activity response onto the
// stat-card view model. Shared by the static and TUI render paths.
func statsFromActivity(activity *userActivityResponse) contributionStats {
return contributionStats{
Tasks: activity.Stats.Tasks,
Throughput: activity.Stats.Throughput,
Iteration: activity.Stats.Iteration,
ContinuityH: activity.Stats.ContinuityHours,
Streak: activity.Stats.LifetimeStreak,
CurrentStreak: activity.Stats.LifetimeCurrentStreak,
}
days := groupCommitsByDay(commits)

sty := newActivityStyles(w)
renderActivity(w, sty, stats, activity.Repos, activity.HourlyContributions, days)
return nil
}

// fetchActivityData fetches aggregated activity and commits concurrently.
func fetchActivityData(ctx context.Context, client *api.Client) (*userActivityResponse, []userCommit, error) {
// fetchActivityWith fetches the always-needed /me/activity aggregate
// concurrently with the caller's chosen recent-list fetch (sessions or
// commits). Either fetch failing fails the whole call.
func fetchActivityWith[T any](ctx context.Context, client *api.Client, fetchList func(context.Context, *api.Client) (T, error)) (*userActivityResponse, T, error) {
var activity *userActivityResponse
var commits []userCommit
var list T

g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
Expand All @@ -100,13 +123,13 @@ func fetchActivityData(ctx context.Context, client *api.Client) (*userActivityRe
})
g.Go(func() error {
var err error
commits, err = fetchCommits(gCtx, client)
list, err = fetchList(gCtx, client)
return err
})
if err := g.Wait(); err != nil {
return nil, nil, fmt.Errorf("fetch activity: %w", err)
return nil, list, fmt.Errorf("fetch activity: %w", err)
}
return activity, commits, nil
return activity, list, nil
}

func fetchActivity(ctx context.Context, client *api.Client) (*userActivityResponse, error) {
Expand Down Expand Up @@ -152,6 +175,29 @@ func fetchCommits(ctx context.Context, client *api.Client) ([]userCommit, error)
return result.Commits, nil
}

func fetchSessions(ctx context.Context, client *api.Client) ([]userSession, error) {
q := url.Values{}
q.Set("timeframe", activityTimeframe)
q.Set("limit", strconv.Itoa(sessionsOverviewLimit))
path := "/api/v1/me/sessions?" + q.Encode()

resp, err := client.Get(ctx, path)
if err != nil {
return nil, fmt.Errorf("GET sessions: %w", err)
}
defer resp.Body.Close()

if err := api.CheckResponse(resp); err != nil {
return nil, fmt.Errorf("sessions response: %w", err)
}

var result userSessionsResponse
if err := api.DecodeJSON(resp, &result); err != nil {
return nil, fmt.Errorf("decode sessions: %w", err)
}
return result.Sessions, nil
}

// detectTimezone returns a best-effort timezone name for the current host.
// Order: $TZ → /etc/localtime symlink → time.Local → "UTC" as last resort.
// A candidate that fails normalization is skipped (not forwarded, not coerced
Expand Down Expand Up @@ -201,41 +247,65 @@ func normalizeTimezone(raw string) string {
return name
}

func groupCommitsByDay(commits []userCommit) []commitDay {
byDate := make(map[string][]userCommit)
var dateOrder []string

for _, c := range commits {
date := dateUnknown
if c.CommitDate != nil {
if t, err := parseFlexibleTime(*c.CommitDate); err == nil {
date = t.Local().Format("2006-01-02")
}
}
// localDayOf returns the local "2006-01-02" day of an RFC3339 timestamp, or
// dateUnknown when the pointer is nil/empty or the value can't be parsed.
func localDayOf(ts *string) string {
if ts == nil || *ts == "" {
return dateUnknown
}
t, err := parseFlexibleTime(*ts)
if err != nil {
return dateUnknown
}
return t.Local().Format("2006-01-02")
}

// groupItemsByDay buckets items by a local-day key, returning the distinct keys
// ordered newest-first (with dateUnknown pushed to the end) plus the by-day map.
// Shared by the commit and session day-grouped lists.
func groupItemsByDay[T any](items []T, dayOf func(T) string) (order []string, byDate map[string][]T) {
byDate = make(map[string][]T)
for _, it := range items {
date := dayOf(it)
if _, exists := byDate[date]; !exists {
dateOrder = append(dateOrder, date)
order = append(order, date)
}
byDate[date] = append(byDate[date], c)
byDate[date] = append(byDate[date], it)
}

// Sort dates newest first, with unknown dates pushed to the end
sort.Slice(dateOrder, func(i, j int) bool {
if dateOrder[i] == dateUnknown {
sort.Slice(order, func(i, j int) bool {
if order[i] == dateUnknown {
return false
}
if dateOrder[j] == dateUnknown {
if order[j] == dateUnknown {
return true
}
return dateOrder[i] > dateOrder[j]
return order[i] > order[j]
})
return order, byDate
}

result := make([]commitDay, 0, len(dateOrder))
for _, d := range dateOrder {
func groupCommitsByDay(commits []userCommit) []commitDay {
order, byDate := groupItemsByDay(commits, func(c userCommit) string {
return localDayOf(c.CommitDate)
})
result := make([]commitDay, 0, len(order))
for _, d := range order {
result = append(result, commitDay{Date: d, Commits: byDate[d]})
}
return result
}

func groupSessionsByDay(sessions []userSession) []sessionDay {
order, byDate := groupItemsByDay(sessions, func(s userSession) string {
return localDayOf(&s.LastActivityAt)
})
result := make([]sessionDay, 0, len(order))
for _, d := range order {
result = append(result, sessionDay{Date: d, Sessions: byDate[d]})
}
return result
}

func normalizeAgentString(s string) string {
if s == "" {
return agentUnknown
Expand Down
4 changes: 2 additions & 2 deletions cmd/entire/cli/activity_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestRunActivity_SilencesContextCanceled(t *testing.T) {
}))

var out, errOut bytes.Buffer
err := runActivity(t.Context(), &out, &errOut)
err := runActivity(t.Context(), &out, &errOut, false)
if err == nil {
t.Fatal("expected error when STS exchange is cancelled")
}
Expand Down Expand Up @@ -74,7 +74,7 @@ func TestRunActivity_PrintsLoginHintOnNotLoggedIn(t *testing.T) {
}))

var out, errOut bytes.Buffer
err := runActivity(t.Context(), &out, &errOut)
err := runActivity(t.Context(), &out, &errOut, false)
if err == nil {
t.Fatal("expected error when not logged in")
}
Expand Down
116 changes: 114 additions & 2 deletions cmd/entire/cli/activity_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,17 @@ var agentOrder = []string{
"copilot", "pi", "cursor", "droid", "kiro", "unknown",
}

func renderActivity(w io.Writer, sty activityStyles, stats contributionStats, repos []repoContribution, hourly []hourlyPoint, days []commitDay) {
// renderActivityHeader renders the stat cards, contribution heatmap, and repo
// chart — the sections common to both the sessions and commits views. The
// caller renders the recent-list section (sessions or commits) after it.
func renderActivityHeader(w io.Writer, sty activityStyles, stats contributionStats, repos []repoContribution, hourly []hourlyPoint) {
fmt.Fprintln(w)
renderStatCards(w, sty, stats)
fmt.Fprintln(w)
renderContributionChart(w, sty, hourly, repos)
fmt.Fprintln(w)
renderRepoChart(w, sty, repos)
fmt.Fprintln(w)
renderCommitList(w, sty, days)
}

func renderStatCards(w io.Writer, sty activityStyles, stats contributionStats) {
Expand Down Expand Up @@ -524,6 +526,116 @@ func renderCommitListN(w io.Writer, sty activityStyles, days []commitDay, maxDay
}
}

// sessionTitleMaxRunes caps the display-name *content* at 120 runes, matching
// entire.io's Overview row (`displayName.slice(0, 120) + "…"`): the ellipsis is
// appended as an overflow marker on top of the 120, so the rendered title can
// be 121 runes — this is a content cap, not a hard total-length cap. On a real
// terminal the width-based truncation below usually shortens it further first.
const sessionTitleMaxRunes = 120

func renderSessionList(w io.Writer, sty activityStyles, days []sessionDay) {
renderSessionListN(w, sty, days, 3)
}

// renderSessionListN renders the recent-session feed grouped by day (newest
// first), mirroring the entire.io Overview list: a per-day header with a
// session count, then one row per session. maxDays <= 0 renders every day.
func renderSessionListN(w io.Writer, sty activityStyles, days []sessionDay, maxDays int) {
if len(days) == 0 {
return
}
if maxDays <= 0 || maxDays > len(days) {
maxDays = len(days)
}

for _, day := range days[:maxDays] {
displayDate := formatCommitDate(day.Date)
sessionWord := "sessions"
if len(day.Sessions) == 1 {
sessionWord = strings.TrimSuffix(sessionWord, "s")
}

fmt.Fprintf(w, "%s %s\n",
sty.render(sty.bold, displayDate),
sty.render(sty.muted, fmt.Sprintf("%d %s", len(day.Sessions), sessionWord)))

for _, s := range day.Sessions {
renderSessionRow(w, sty, s)
}
fmt.Fprintln(w)
}
}

// renderSessionRow renders one session: title repo [public] agent … model checkpoints.
// Left side is the session's display name, repo, an optional public tag, and
// the agent badge; right side (right-aligned) is the friendly model label and
// checkpoint count. Fields mirror the entire.io Overview row.
func renderSessionRow(w io.Writer, sty activityStyles, s userSession) {
agentID := agentUnknown
if s.Agent != nil && *s.Agent != "" {
agentID = normalizeAgentString(*s.Agent)
}
agentLabel := agentDisplayMap[agentID].Label

title := strings.TrimSpace(s.DisplayName)
if title == "" {
title = "(untitled session)"
}
if runes := []rune(title); len(runes) > sessionTitleMaxRunes {
title = string(runes[:sessionTitleMaxRunes]) + "…"
}
Comment thread
Soph marked this conversation as resolved.

model := ""
if s.Model != nil {
model = formatModel(*s.Model)
}

cpStr := fmt.Sprintf("%d checkpoints", s.CheckpointCount)
if s.CheckpointCount == 1 {
cpStr = "1 checkpoint"
}

// Right side: [model ]checkpoints, right-aligned.
rightSide := sty.render(sty.muted, cpStr)
rightPlain := cpStr
if model != "" {
rightSide = sty.render(sty.muted, model) + sty.render(sty.dim, " ") + rightSide
rightPlain = model + " " + cpStr
}

// Public tag (rare from the entire-api cell, which reports isPublic=false).
publicRendered, publicPlain := "", ""
if s.IsPublic {
publicRendered = " " + sty.render(sty.add, "public")
publicPlain = " public"
}

buildLeft := func(t string) (rendered, plain string) {
rendered = sty.render(sty.commitM, t) + " " +
sty.render(sty.muted, s.RepoFullName) + publicRendered +
" " + sty.renderAgent(agentID, agentLabel)
plain = t + " " + s.RepoFullName + publicPlain + " " + agentLabel
return rendered, plain
}
left, leftPlain := buildLeft(title)

// Truncate the title if the row would exceed the terminal width.
maxTitle := sty.width - (lipgloss.Width(leftPlain) - lipgloss.Width(title)) - lipgloss.Width(rightPlain) - 2
if maxTitle < 10 {
maxTitle = 10
}
if lipgloss.Width(title) > maxTitle {
title = truncateDisplayWidth(title, maxTitle, "…")
left, leftPlain = buildLeft(title)
}

gap := sty.width - lipgloss.Width(leftPlain) - lipgloss.Width(rightPlain)
if gap < 2 {
gap = 2
}
fmt.Fprintf(w, "%s%s%s\n", left, strings.Repeat(" ", gap), rightSide)
}

func uniqueCommitAgents(c userCommit) []string {
seen := make(map[string]struct{})
var result []string
Expand Down
Loading
Loading