diff --git a/cmd/entire/cli/activity_cmd.go b/cmd/entire/cli/activity_cmd.go index af07acc828..7cfedb1329 100644 --- a/cmd/entire/cli/activity_cmd.go +++ b/cmd/entire/cli/activity_cmd.go @@ -31,36 +31,40 @@ const ( // map, the values knownAgents normalizes onto, and the render order all draw // from this one set. const ( - activityAgentClaude = "claude" - activityAgentGemini = "gemini" - activityAgentAmp = "amp" - activityAgentCodex = "codex" - activityAgentOpencode = "opencode" - activityAgentCopilot = "copilot" - activityAgentPi = "pi" - activityAgentCursor = "cursor" - activityAgentDroid = "droid" - activityAgentKiro = "kiro" - activityAgentUnknown = "unknown" + activityAgentClaude = "claude" + activityAgentGemini = "gemini" + activityAgentAmp = "amp" + activityAgentCodex = "codex" + activityAgentOpencode = "opencode" + activityAgentCopilot = "copilot" + activityAgentPi = "pi" + activityAgentCursor = "cursor" + activityAgentDroid = "droid" + activityAgentKiro = "kiro" + activityAgentAntigravity = "antigravity" + activityAgentGoose = "goose" + activityAgentUnknown = "unknown" ) // knownAgents maps normalized agent strings from the API to display IDs. // Used for the commit list, where per-checkpoint agent strings are free-form. // The /me/activity endpoint returns already-normalized canonical IDs. var knownAgents = map[string]string{ - "claude": activityAgentClaude, - "claudecode": activityAgentClaude, - "gemini": activityAgentGemini, - "geminicli": activityAgentGemini, - "amp": activityAgentAmp, - "codex": activityAgentCodex, - "opencode": activityAgentOpencode, - "copilot": activityAgentCopilot, - "copilotcli": activityAgentCopilot, - "pi": activityAgentPi, - "cursor": activityAgentCursor, - "droid": activityAgentDroid, - "kiro": activityAgentKiro, + "claude": activityAgentClaude, + "claudecode": activityAgentClaude, + "gemini": activityAgentGemini, + "geminicli": activityAgentGemini, + "amp": activityAgentAmp, + "codex": activityAgentCodex, + "opencode": activityAgentOpencode, + "copilot": activityAgentCopilot, + "copilotcli": activityAgentCopilot, + "pi": activityAgentPi, + "cursor": activityAgentCursor, + "droid": activityAgentDroid, + "kiro": activityAgentKiro, + "antigravity": activityAgentAntigravity, + "goose": activityAgentGoose, } func newActivityCmd() *cobra.Command { diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index 38844320b5..b2e0fa60d3 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -5,10 +5,12 @@ import ( "io" "math" "os" + "slices" "sort" "strconv" "strings" "time" + "unicode" "charm.land/lipgloss/v2" "github.com/entireio/cli/cmd/entire/cli/palette" @@ -90,7 +92,7 @@ func (s activityStyles) renderAgent(agentID, text string) string { if !s.colorEnabled { return text } - display := agentDisplayMap[agentID] + display := agentDisplayFor(agentID) return lipgloss.NewStyle().Foreground(lipgloss.Color(display.Color)).Render(text) } @@ -108,22 +110,158 @@ type agentDisplay struct { // recognizable; lipgloss resolves them to the best representation for the // terminal's color profile. The non-brand "unknown" fallback uses muted gray. var agentDisplayMap = map[string]agentDisplay{ - activityAgentClaude: {Label: "Claude Code", Color: "#fb923c", Char: '▓'}, // orange-400 - activityAgentGemini: {Label: "Gemini", Color: "#60a5fa", Char: '▓'}, // blue-400 - activityAgentAmp: {Label: "Amp", Color: "#f87171", Char: '▓'}, // red-400 - activityAgentCodex: {Label: "Codex", Color: "#818cf8", Char: '▓'}, // indigo-400 - activityAgentOpencode: {Label: "OpenCode", Color: "#22d3ee", Char: '▓'}, // cyan-400 - activityAgentCopilot: {Label: "Copilot", Color: "#a78bfa", Char: '▓'}, // violet-400 - activityAgentPi: {Label: "Pi", Color: "#fbbf24", Char: '▓'}, // amber-400 - activityAgentCursor: {Label: "Cursor", Color: "#38bdf8", Char: '▓'}, // sky-400 - activityAgentDroid: {Label: "Droid", Color: "#f472b6", Char: '▓'}, // pink-400 - activityAgentKiro: {Label: "Kiro", Color: "#c084fc", Char: '▓'}, // purple-400 - activityAgentUnknown: {Label: "Unknown", Color: palette.Muted, Char: '░'}, + activityAgentClaude: {Label: "Claude Code", Color: "#fb923c", Char: '▓'}, // orange-400 + activityAgentGemini: {Label: "Gemini", Color: "#60a5fa", Char: '▓'}, // blue-400 + activityAgentAmp: {Label: "Amp", Color: "#f87171", Char: '▓'}, // red-400 + activityAgentCodex: {Label: "Codex", Color: "#818cf8", Char: '▓'}, // indigo-400 + activityAgentOpencode: {Label: "OpenCode", Color: "#22d3ee", Char: '▓'}, // cyan-400 + activityAgentCopilot: {Label: "Copilot", Color: "#a78bfa", Char: '▓'}, // violet-400 + activityAgentPi: {Label: "Pi", Color: "#fbbf24", Char: '▓'}, // amber-400 + activityAgentCursor: {Label: "Cursor", Color: "#38bdf8", Char: '▓'}, // sky-400 + activityAgentDroid: {Label: "Droid", Color: "#f472b6", Char: '▓'}, // pink-400 + activityAgentKiro: {Label: "Kiro", Color: "#c084fc", Char: '▓'}, // purple-400 + activityAgentAntigravity: {Label: "Antigravity", Color: "#2dd4bf", Char: '▓'}, // teal-400 + activityAgentGoose: {Label: "Goose", Color: "#a3e635", Char: '▓'}, // lime-400 + activityAgentUnknown: {Label: "Unknown", Color: palette.Muted, Char: externalAgentBarChar}, +} + +// agentKey is the built-in agent ID for raw, or raw itself for an agent Entire +// does not know (an external agent), so each keeps its own name. +func agentKey(raw string) string { + if id := normalizeAgentString(raw); id != agentUnknown { + return id + } + // The CLI itself records an unidentified agent as "Unknown" + // (agent.AgentTypeUnknown); any casing of it is the unknown bucket. + if name := externalAgentName(raw); name != "" && !strings.EqualFold(name, agentUnknown) { + return name + } + return agentUnknown +} + +// externalAgentBarChar fills the bar segment of an agent without its own +// colour (external agents and Unknown). It differs from the bar's empty track +// ('░') so their share stays visible without colour. +const externalAgentBarChar = '▒' + +// maxExternalAgentNameRunes caps a self-reported name before it is cleaned and +// measured, so an absurdly long one cannot stall rendering. +const maxExternalAgentNameRunes = 256 + +// zeroWidthJoiner joins emoji sequences, so it is kept inside a name. +const zeroWidthJoiner = '\u200d' + +// externalAgentName is raw without control or invisible format characters +// (bidi controls, zero-width spaces, BOM, soft hyphens), trimmed. External +// agent names are self-reported, so escape sequences must not reach the +// terminal, and the web and API clean names the same way. +func externalAgentName(raw string) string { + if runes := []rune(raw); len(runes) > maxExternalAgentNameRunes { + raw = string(runes[:maxExternalAgentNameRunes]) + } + cleaned := strings.Map(func(r rune) rune { + if unicode.IsControl(r) || (unicode.Is(unicode.Cf, r) && r != zeroWidthJoiner) { + return -1 + } + return r + }, raw) + return strings.TrimFunc(cleaned, func(r rune) bool { + return unicode.IsSpace(r) || r == zeroWidthJoiner + }) +} + +// agentCounts re-keys counts by agentKey, so raw names that clean to the same +// agent (or to a built-in) are counted once, and merges external names that +// differ only in case. +func agentCounts(counts map[string]int) map[string]int { + out := make(map[string]int, len(counts)) + for raw, count := range counts { + out[agentKey(raw)] += count + } + return mergeCaseVariants(out) +} + +// mergeCaseVariants folds agent keys that differ only in case into one key, +// spelled the way most of the counts spell it (ties go to the smaller +// spelling), as entire-api does for its chart series. Built-in IDs are left +// alone: Unicode case folding maps names such as "\u212airo" (Kelvin sign) onto +// a built-in ID, and a self-reported name must not take over that agent's +// share. +func mergeCaseVariants(counts map[string]int) map[string]int { + type variants struct { + total, bestCount int + best string + } + out := make(map[string]int, len(counts)) + groups := make(map[string]*variants, len(counts)) + for key, count := range counts { + if _, builtIn := agentDisplayMap[key]; builtIn { + out[key] += count + continue + } + folded := strings.ToLower(key) + g, ok := groups[folded] + if !ok { + g = &variants{} + groups[folded] = g + } + g.total += count + if g.best == "" || count > g.bestCount || (count == g.bestCount && key < g.best) { + g.best, g.bestCount = key, count + } + } + for _, g := range groups { + out[g.best] += g.total + } + return out +} + +// maxExternalAgentLabelWidth bounds an external agent's self-reported name so +// a long one cannot push session and commit rows past the terminal width. +const maxExternalAgentLabelWidth = 24 + +// agentDisplayFor is agentDisplayMap[key], or a plain entry labelled with the +// key for an external agent. +func agentDisplayFor(key string) agentDisplay { + if d, ok := agentDisplayMap[key]; ok { + return d + } + if name := externalAgentName(key); name != "" { + return agentDisplay{Label: truncateDisplayWidth(name, maxExternalAgentLabelWidth, "…"), Color: palette.Muted, Char: externalAgentBarChar} + } + return agentDisplayMap[activityAgentUnknown] } var agentOrder = []string{ activityAgentClaude, activityAgentCodex, activityAgentGemini, activityAgentAmp, activityAgentOpencode, - activityAgentCopilot, activityAgentPi, activityAgentCursor, activityAgentDroid, activityAgentKiro, activityAgentUnknown, + activityAgentCopilot, activityAgentPi, activityAgentCursor, activityAgentDroid, activityAgentKiro, + activityAgentAntigravity, activityAgentGoose, activityAgentUnknown, +} + +// agentRenderOrder is the keys of counts in display order: built-in agents in +// agentOrder, then external agents by name, then Unknown. +func agentRenderOrder(counts map[string]int) []string { + keys := make([]string, 0, len(counts)) + for _, id := range agentOrder { + if id == activityAgentUnknown { + break + } + if counts[id] > 0 { + keys = append(keys, id) + } + } + var external []string + for id, count := range counts { + if count > 0 && !slices.Contains(agentOrder, id) { + external = append(external, id) + } + } + slices.Sort(external) + keys = append(keys, external...) + if counts[activityAgentUnknown] > 0 { + keys = append(keys, activityAgentUnknown) + } + return keys } // renderActivityHeader renders the stat cards, contribution heatmap, and repo @@ -179,14 +317,17 @@ func renderContributionChart(w io.Writer, sty activityStyles, hourly []hourlyPoi } func renderDotChart(w io.Writer, sty activityStyles, hourly []hourlyPoint, repos []repoContribution) { - agentTotals := make(map[string]int) + // Sum raw names before normalizing, so one agent spelled differently in + // two repos is still one legend entry. + rawTotals := make(map[string]int) total := 0 for _, r := range repos { total += r.Total for agent, count := range r.Agents { - agentTotals[agent] += count + rawTotals[agent] += count } } + agentTotals := agentCounts(rawTotals) totalLabel := "" if total > 0 { @@ -315,13 +456,9 @@ func renderDotChart(w io.Writer, sty activityStyles, hourly []hourlyPoint, repos // Agent legend if total > 0 { var parts []string - for _, id := range agentOrder { - count, ok := agentTotals[id] - if !ok || count == 0 { - continue - } - pct := float64(count) / float64(total) * 100 - display := agentDisplayMap[id] + for _, id := range agentRenderOrder(agentTotals) { + pct := float64(agentTotals[id]) / float64(total) * 100 + display := agentDisplayFor(id) parts = append(parts, sty.renderAgent(id, fmt.Sprintf("● %s %d%%", display.Label, int(math.Round(pct))))) } fmt.Fprintln(w, strings.Join(parts, sty.render(sty.dim, " "))) @@ -381,12 +518,10 @@ func renderAgentBar(sty activityStyles, agents map[string]int, maxCount, barWidt var b strings.Builder + agents = agentCounts(agents) filled := 0 - for _, id := range agentOrder { - count, ok := agents[id] - if !ok || count == 0 { - continue - } + for _, id := range agentRenderOrder(agents) { + count := agents[id] segWidth := int(math.Round(float64(count) / float64(maxCount) * float64(barWidth))) if segWidth < 1 && count > 0 { segWidth = 1 @@ -398,7 +533,7 @@ func renderAgentBar(sty activityStyles, agents map[string]int, maxCount, barWidt continue } - display := agentDisplayMap[id] + display := agentDisplayFor(id) seg := strings.Repeat(string(display.Char), segWidth) b.WriteString(sty.renderAgent(id, seg)) filled += segWidth @@ -448,8 +583,7 @@ func renderCommitListN(w io.Writer, sty activityStyles, days []commitDay, maxDay var badges []string for _, a := range uniqueCommitAgents(c) { - display := agentDisplayMap[a] - badges = append(badges, sty.renderAgent(a, display.Label)) + badges = append(badges, sty.renderAgent(a, agentDisplayFor(a).Label)) } fileStats := fmt.Sprintf("%d files", c.FilesChanged) @@ -494,7 +628,7 @@ func renderCommitListN(w io.Writer, sty activityStyles, days []commitDay, maxDay left += leftSb359.String() var leftPlainSb362 strings.Builder for _, a := range uniqueCommitAgents(c) { - leftPlainSb362.WriteString(" " + agentDisplayMap[a].Label) + leftPlainSb362.WriteString(" " + agentDisplayFor(a).Label) } leftPlain += leftPlainSb362.String() @@ -517,7 +651,7 @@ func renderCommitListN(w io.Writer, sty activityStyles, days []commitDay, maxDay left += leftSb378.String() var leftPlainSb381 strings.Builder for _, a := range uniqueCommitAgents(c) { - leftPlainSb381.WriteString(" " + agentDisplayMap[a].Label) + leftPlainSb381.WriteString(" " + agentDisplayFor(a).Label) } leftPlain += leftPlainSb381.String() } @@ -578,10 +712,10 @@ func renderSessionListN(w io.Writer, sty activityStyles, days []sessionDay, maxD // 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) + if s.Agent != nil { + agentID = agentKey(*s.Agent) } - agentLabel := agentDisplayMap[agentID].Label + agentLabel := agentDisplayFor(agentID).Label title := strings.TrimSpace(s.DisplayName) if title == "" { @@ -643,8 +777,7 @@ func renderSessionRow(w io.Writer, sty activityStyles, s userSession) { } func uniqueCommitAgents(c userCommit) []string { - seen := make(map[string]struct{}) - var result []string + seen := make(map[string]int) for _, cp := range c.Checkpoints { agents := cp.Agents // Fall back to singular Agent field when Agents slice is empty @@ -652,13 +785,14 @@ func uniqueCommitAgents(c userCommit) []string { agents = []string{cp.Agent} } for _, a := range agents { - id := normalizeAgentString(a) - if _, ok := seen[id]; !ok { - seen[id] = struct{}{} - result = append(result, id) - } + seen[agentKey(a)]++ } } + merged := mergeCaseVariants(seen) + result := make([]string, 0, len(merged)) + for id := range merged { + result = append(result, id) + } sort.Strings(result) return result } diff --git a/cmd/entire/cli/activity_render_test.go b/cmd/entire/cli/activity_render_test.go index 6b2978ea38..f3011d53c4 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -3,9 +3,13 @@ package cli import ( "bytes" "os" + "slices" "strings" "testing" + "time" "unicode/utf8" + + "charm.land/lipgloss/v2" ) const activityTestAgentClaude = "claude" @@ -75,6 +79,177 @@ func TestUniqueCommitAgents_Empty(t *testing.T) { } } +// External agents keep their own name instead of all folding into "Unknown". +func TestUniqueCommitAgents_KeepsExternalAgentName(t *testing.T) { + t.Parallel() + c := userCommit{ + Checkpoints: []userCommitCheckpoint{ + {Agents: []string{"Claude Code", "Grok Bot"}}, + {Agents: []string{"Qwen Coder (PC)"}}, + }, + } + got := uniqueCommitAgents(c) + want := []string{"Grok Bot", "Qwen Coder (PC)", activityTestAgentClaude} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("got %v, want %v", got, want) + } +} + +// External agent names are self-reported; escape sequences must not reach the +// terminal. +func TestAgentKey_StripsControlCharacters(t *testing.T) { + t.Parallel() + if got := agentKey("Grok\x1b[2J Bot\u202e\n"); got != "Grok[2J Bot" { + t.Errorf("agentKey = %q, want %q", got, "Grok[2J Bot") + } + if got := agentKey("\x1b\x07"); got != agentUnknown { + t.Errorf("agentKey of only control characters = %q, want %q", got, agentUnknown) + } + if got := agentDisplayFor("Grok\x1b[2J Bot").Label; got != "Grok[2J Bot" { + t.Errorf("agentDisplayFor label = %q, want %q", got, "Grok[2J Bot") + } +} + +func TestAgentDisplayFor_TruncatesLongExternalName(t *testing.T) { + t.Parallel() + label := agentDisplayFor(strings.Repeat("界", 40)).Label + if w := lipgloss.Width(label); w > maxExternalAgentLabelWidth { + t.Errorf("label width = %d, want <= %d (%q)", w, maxExternalAgentLabelWidth, label) + } + if !strings.HasPrefix(label, "界界") || !strings.HasSuffix(label, "…") { + t.Errorf("label = %q, want the name's start cut with …", label) + } +} + +// A huge self-reported name is capped before it is measured, so rendering +// stays fast and the label stays within its width. +func TestAgentDisplayFor_HugeNameIsCappedBeforeMeasuring(t *testing.T) { + t.Parallel() + name := strings.Repeat("a", 23) + strings.Repeat("\u0301", 100_000) + "bb" + start := time.Now() + label := agentDisplayFor(agentKey(name)).Label + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("label took %v, want under 2s", elapsed) + } + if label == "" || lipgloss.Width(label) > maxExternalAgentLabelWidth { + t.Errorf("label = %q (width %d), want non-empty and <= %d", label, lipgloss.Width(label), maxExternalAgentLabelWidth) + } +} + +// Invisible format characters are stripped, as on the web and in the API, so +// a name cannot pass as another one. +func TestAgentKey_StripsInvisibleFormatCharacters(t *testing.T) { + t.Parallel() + for raw, want := range map[string]string{ + "Foo\u200b": "Foo", + "\ufeffGrok\u2060 Bot": "Grok Bot", + "Ro\u00adger": "Roger", + "\u200dFoo\u200d": "Foo", + "\U0001F469\u200d\U0001F4BB Dev": "\U0001F469\u200d\U0001F4BB Dev", + } { + if got := agentKey(raw); got != want { + t.Errorf("agentKey(%q) = %q, want %q", raw, got, want) + } + } +} + +// Goose and Antigravity are built-in agents in entire-api, so they get their +// own label rather than being shown as an external agent's raw id. +func TestAgentKey_GooseAndAntigravityAreBuiltIn(t *testing.T) { + t.Parallel() + for raw, want := range map[string]string{"goose": activityAgentGoose, "Antigravity": activityAgentAntigravity} { + if got := agentKey(raw); got != want { + t.Errorf("agentKey(%q) = %q, want built-in %q", raw, got, want) + } + } +} + +func TestRenderSessionRow_AgentLabel(t *testing.T) { + t.Parallel() + for name, tc := range map[string]struct { + agent *string + want string + }{ + "built-in": {strPtr("claude_code"), "Claude Code"}, + "external": {strPtr("Grok Bot"), "Grok Bot"}, + "missing": {nil, "Unknown"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + renderSessionRow(&buf, activityStyles{width: 120}, userSession{DisplayName: "s", Agent: tc.agent}) + if !strings.Contains(buf.String(), tc.want) { + t.Errorf("row = %q, want agent %q", buf.String(), tc.want) + } + }) + } +} + +// Repo bars and the legend list external agents after the built-ins and +// before Unknown, instead of dropping them. +func TestAgentRenderOrder_IncludesExternalAgents(t *testing.T) { + t.Parallel() + got := agentRenderOrder(map[string]int{ + activityAgentUnknown: 1, + "Grok Bot": 2, + activityTestAgentClaude: 3, + "Aider": 4, + activityAgentCodex: 0, + }) + want := []string{activityTestAgentClaude, "Aider", "Grok Bot", activityAgentUnknown} + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestRenderDotChart_LegendNamesExternalAgent(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + repos := []repoContribution{{Repo: "r", Total: 4, Agents: map[string]int{activityTestAgentClaude: 1, "Grok Bot": 3}}} + hourly := []hourlyPoint{{Date: "2026-04-01", Hour: 12, Value: 4, AgentID: activityTestAgentClaude}} + renderDotChart(&buf, activityStyles{width: 200}, hourly, repos) + if !strings.Contains(buf.String(), "Grok Bot 75%") { + t.Errorf("legend missing external agent:\n%s", buf.String()) + } +} + +// Raw names that clean to the same agent are one legend entry, and an empty +// name is Unknown. +func TestRenderDotChart_LegendMergesCleanedNames(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + repos := []repoContribution{{Repo: "r", Total: 12, Agents: map[string]int{"Grok": 5, "Grok\x1b": 5, "": 2}}} + hourly := []hourlyPoint{{Date: "2026-04-01", Hour: 12, Value: 12, AgentID: "Grok"}} + renderDotChart(&buf, activityStyles{width: 200}, hourly, repos) + out := buf.String() + if strings.Count(out, "Grok") != 1 || !strings.Contains(out, "Grok 83%") || !strings.Contains(out, "Unknown 17%") { + t.Errorf("legend should list Grok once at 83%% and Unknown at 17%%:\n%s", out) + } + + buf.Reset() + repos = []repoContribution{ + {Repo: "a", Total: 3, Agents: map[string]int{"Grok Bot": 3}}, + {Repo: "b", Total: 1, Agents: map[string]int{"grok bot": 1}}, + } + renderDotChart(&buf, activityStyles{width: 200}, hourly, repos) + if out := buf.String(); !strings.Contains(out, "Grok Bot 100%") || strings.Contains(out, "grok bot") { + t.Errorf("legend should merge one agent's spellings across repos:\n%s", out) + } +} + +// Without colour, an external agent's share must not look like the empty +// track. +func TestRenderAgentBar_ExternalAgentDiffersFromEmptyTrack(t *testing.T) { + t.Parallel() + bar := renderAgentBar(activityStyles{width: 120}, map[string]int{"Grok": 5, activityTestAgentClaude: 3}, 10, 20) + if got := strings.Count(bar, string(externalAgentBarChar)); got != 10 { + t.Errorf("bar = %q, want 10 external cells", bar) + } + if got := strings.Count(bar, "░"); got != 4 { + t.Errorf("bar = %q, want 4 empty-track cells", bar) + } +} + func TestRenderStatCards_ContainsAllLabels(t *testing.T) { t.Parallel() var buf bytes.Buffer @@ -376,3 +551,40 @@ func TestRunStatsTUI_NoColorStyleFlag(t *testing.T) { t.Fatal("expected stats TUI styles to disable colors") } } + +// The CLI records an unidentified agent as "Unknown" (agent.AgentTypeUnknown); +// it must share the canonical unknown bucket, not become an external agent. +func TestAgentKey_CapitalizedUnknownIsUnknown(t *testing.T) { + t.Parallel() + if got := agentKey("Unknown"); got != activityAgentUnknown { + t.Errorf("agentKey(%q) = %q, want %q", "Unknown", got, activityAgentUnknown) + } + counts := agentCounts(map[string]int{"unknown": 2, "Unknown": 3, "UNKNOWN": 1}) + if len(counts) != 1 || counts[activityAgentUnknown] != 6 { + t.Errorf("agentCounts = %v, want {%s:6}", counts, activityAgentUnknown) + } + c := userCommit{Checkpoints: []userCommitCheckpoint{{Agents: []string{"Unknown"}}, {Agents: []string{"unknown"}}}} + if got := uniqueCommitAgents(c); len(got) != 1 || got[0] != activityAgentUnknown { + t.Errorf("uniqueCommitAgents = %v, want a single unknown badge", got) + } +} + +func TestAgentCounts_MergesExternalCaseVariants(t *testing.T) { + t.Parallel() + counts := agentCounts(map[string]int{"Grok Bot": 3, "grok bot": 1, activityTestAgentClaude: 2}) + if len(counts) != 2 || counts["Grok Bot"] != 4 || counts[activityTestAgentClaude] != 2 { + t.Errorf("agentCounts = %v, want {Grok Bot:4 claude:2}", counts) + } + tied := agentCounts(map[string]int{"grok bot": 1, "Grok Bot": 1}) + if len(tied) != 1 || tied["Grok Bot"] != 2 { + t.Errorf("tied spellings = %v, want the smaller spelling {Grok Bot:2}", tied) + } + kelvin := agentCounts(map[string]int{"\u212airo": 5, activityAgentKiro: 2}) + if len(kelvin) != 2 || kelvin[activityAgentKiro] != 2 || kelvin["\u212airo"] != 5 { + t.Errorf("Kelvin-sign Kiro = %v, want it kept apart from the built-in kiro", kelvin) + } + c := userCommit{Checkpoints: []userCommitCheckpoint{{Agents: []string{"grok bot"}}, {Agents: []string{"Grok Bot", "Grok Bot"}}}} + if got := uniqueCommitAgents(c); len(got) != 1 || got[0] != "Grok Bot" { + t.Errorf("uniqueCommitAgents = %v, want one Grok Bot badge", got) + } +} diff --git a/cmd/entire/cli/activity_types.go b/cmd/entire/cli/activity_types.go index a82a7432c4..1b6655d7b5 100644 --- a/cmd/entire/cli/activity_types.go +++ b/cmd/entire/cli/activity_types.go @@ -2,8 +2,9 @@ package cli // API response types for the /api/v1/me/* endpoints used by `entire activity`. -// activityAgentCounts maps the 11 canonical agent IDs to counts. -// The API always populates every key (zero for absent agents). +// activityAgentCounts maps agent keys to counts: canonical IDs for built-in +// agents, which the API populates with zero when absent, plus any other key it +// sends (e.g. an external agent's name). Zero counts are not rendered. type activityAgentCounts map[string]int // userActivityResponse is the API response for GET /api/v1/me/activity. @@ -70,7 +71,7 @@ type contributionStats struct { } // repoContribution matches the API's `repos[]` shape. Agents is keyed by the -// canonical agent ID (claude, gemini, …, unknown) with all 11 keys populated. +// canonical agent ID (claude, gemini, …, unknown) with every key populated. type repoContribution struct { Repo string `json:"repo"` Total int `json:"total"`