From 606067d711c3f5887816ccdc97cead7ebc2bce0d Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 6 Oct 2026 13:42:09 -0700 Subject: [PATCH 1/7] activity: show an external agent's own name instead of "Unknown" `entire activity` mapped every agent it did not ship with to "Unknown", so sessions and commits from external agents all showed the same label even though the API returns the agent's name. Session rows and commit badges now show that name; only a missing agent reads "Unknown". The hourly legend and repo bars still group agents into the known set. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M49F7JJDE6SXQW17RW4J1BV2 --- cmd/entire/cli/activity_render.go | 38 ++++++++++++++++++++------ cmd/entire/cli/activity_render_test.go | 37 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index 6a29d7649c..1c5d44f1ca 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -90,7 +90,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) } @@ -121,6 +121,27 @@ var agentDisplayMap = map[string]agentDisplay{ activityAgentUnknown: {Label: "Unknown", Color: palette.Muted, Char: '░'}, } +// 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 + } + if name := strings.TrimSpace(raw); name != "" { + return name + } + return agentUnknown +} + +// 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 + } + return agentDisplay{Label: key, Color: palette.Muted, Char: '░'} +} + var agentOrder = []string{ activityAgentClaude, activityAgentCodex, activityAgentGemini, activityAgentAmp, activityAgentOpencode, activityAgentCopilot, activityAgentPi, activityAgentCursor, activityAgentDroid, activityAgentKiro, activityAgentUnknown, @@ -448,8 +469,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 +514,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 +537,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 +598,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 == "" { @@ -652,7 +672,7 @@ func uniqueCommitAgents(c userCommit) []string { agents = []string{cp.Agent} } for _, a := range agents { - id := normalizeAgentString(a) + id := agentKey(a) if _, ok := seen[id]; !ok { seen[id] = struct{}{} result = append(result, id) diff --git a/cmd/entire/cli/activity_render_test.go b/cmd/entire/cli/activity_render_test.go index 6b2978ea38..1bd5bd5fa3 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -75,6 +75,43 @@ 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) + } +} + +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) + } + }) + } +} + func TestRenderStatCards_ContainsAllLabels(t *testing.T) { t.Parallel() var buf bytes.Buffer From 664d549d49fbb1a75ff6b1d92fd401f7ff93f342 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 7 Oct 2026 15:50:09 -0700 Subject: [PATCH 2/7] activity: list external agents in the repo bars and legend The contributions legend and repository bars walked only the built-in agent list, so an agent outside it was dropped. Render built-ins, then external agents by name, then Unknown. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4C8YP3M46G6EY69XFAMGP2K --- cmd/entire/cli/activity_render.go | 46 ++++++++++++++++++-------- cmd/entire/cli/activity_render_test.go | 29 ++++++++++++++++ 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index 1c5d44f1ca..88dd1a073f 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -5,6 +5,7 @@ import ( "io" "math" "os" + "slices" "sort" "strconv" "strings" @@ -147,6 +148,32 @@ var agentOrder = []string{ activityAgentCopilot, activityAgentPi, activityAgentCursor, activityAgentDroid, activityAgentKiro, 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 // chart — the sections common to both the sessions and commits views. The // caller renders the recent-list section (sessions or commits) after it. @@ -336,13 +363,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, " "))) @@ -403,11 +426,8 @@ func renderAgentBar(sty activityStyles, agents map[string]int, maxCount, barWidt var b strings.Builder 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 @@ -419,7 +439,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 diff --git a/cmd/entire/cli/activity_render_test.go b/cmd/entire/cli/activity_render_test.go index 1bd5bd5fa3..7d4070f4ff 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "os" + "slices" "strings" "testing" "unicode/utf8" @@ -112,6 +113,34 @@ func TestRenderSessionRow_AgentLabel(t *testing.T) { } } +// 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()) + } +} + func TestRenderStatCards_ContainsAllLabels(t *testing.T) { t.Parallel() var buf bytes.Buffer From bf508d09fd24ae8586f58ee7902008a9fe5a6821 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 7 Oct 2026 16:04:57 -0700 Subject: [PATCH 3/7] activity: drop control characters from external agent names External agent names are self-reported, so strip control and bidi characters before printing them, as other untrusted strings are. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4C9SSGQMJVJ3RW9XEXYEHA5 --- cmd/entire/cli/activity_render.go | 20 ++++++++++++++++++-- cmd/entire/cli/activity_render_test.go | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index 88dd1a073f..e506fac004 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "time" + "unicode" "charm.land/lipgloss/v2" "github.com/entireio/cli/cmd/entire/cli/palette" @@ -128,19 +129,34 @@ func agentKey(raw string) string { if id := normalizeAgentString(raw); id != agentUnknown { return id } - if name := strings.TrimSpace(raw); name != "" { + if name := externalAgentName(raw); name != "" { return name } return agentUnknown } +// externalAgentName is raw trimmed and without control or bidi characters. +// External agent names are self-reported, so escape sequences must not reach +// the terminal. +func externalAgentName(raw string) string { + return strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) { + return -1 + } + return r + }, raw)) +} + // 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 } - return agentDisplay{Label: key, Color: palette.Muted, Char: '░'} + if name := externalAgentName(key); name != "" { + return agentDisplay{Label: name, Color: palette.Muted, Char: '░'} + } + return agentDisplayMap[activityAgentUnknown] } var agentOrder = []string{ diff --git a/cmd/entire/cli/activity_render_test.go b/cmd/entire/cli/activity_render_test.go index 7d4070f4ff..992e7736c7 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -92,6 +92,21 @@ func TestUniqueCommitAgents_KeepsExternalAgentName(t *testing.T) { } } +// 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 TestRenderSessionRow_AgentLabel(t *testing.T) { t.Parallel() for name, tc := range map[string]struct { From d22abb57b7e2508a045d27edc108d067fd64a288 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 7 Oct 2026 19:07:03 -0700 Subject: [PATCH 4/7] activity: label Goose and Antigravity, bound external agent names entire-api reports Goose and Antigravity as built-in agents, but the CLI did not list them, so they rendered as raw lowercase ids once unknown agents kept their names. Add them. Also cap an external agent's label at 24 cells so a long self-reported name cannot wrap session and commit rows. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4CM7704P9PD7NV6YZK0KKZC --- cmd/entire/cli/activity_cmd.go | 52 ++++++++++++++------------ cmd/entire/cli/activity_render.go | 33 +++++++++------- cmd/entire/cli/activity_render_test.go | 21 +++++++++++ cmd/entire/cli/activity_types.go | 4 +- 4 files changed, 71 insertions(+), 39 deletions(-) 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 e506fac004..c32a02a2e1 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -110,17 +110,19 @@ 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: '░'}, } // agentKey is the built-in agent ID for raw, or raw itself for an agent Entire @@ -147,6 +149,10 @@ func externalAgentName(raw string) string { }, raw)) } +// 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 { @@ -154,14 +160,15 @@ func agentDisplayFor(key string) agentDisplay { return d } if name := externalAgentName(key); name != "" { - return agentDisplay{Label: name, Color: palette.Muted, Char: '░'} + return agentDisplay{Label: truncateDisplayWidth(name, maxExternalAgentLabelWidth, "…"), Color: palette.Muted, Char: '░'} } 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 diff --git a/cmd/entire/cli/activity_render_test.go b/cmd/entire/cli/activity_render_test.go index 992e7736c7..3209494b73 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -6,6 +6,8 @@ import ( "slices" "strings" "testing" + + "charm.land/lipgloss/v2" "unicode/utf8" ) @@ -107,6 +109,25 @@ func TestAgentKey_StripsControlCharacters(t *testing.T) { } } +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) + } +} + +// 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": "Goose", "Antigravity": "Antigravity"} { + if got := agentDisplayFor(agentKey(raw)).Label; got != want { + t.Errorf("label for %q = %q, want %q", raw, got, want) + } + } +} + func TestRenderSessionRow_AgentLabel(t *testing.T) { t.Parallel() for name, tc := range map[string]struct { diff --git a/cmd/entire/cli/activity_types.go b/cmd/entire/cli/activity_types.go index a82a7432c4..5701166ef2 100644 --- a/cmd/entire/cli/activity_types.go +++ b/cmd/entire/cli/activity_types.go @@ -2,7 +2,7 @@ package cli // API response types for the /api/v1/me/* endpoints used by `entire activity`. -// activityAgentCounts maps the 11 canonical agent IDs to counts. +// activityAgentCounts maps the canonical agent IDs to counts. // The API always populates every key (zero for absent agents). type activityAgentCounts map[string]int @@ -70,7 +70,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"` From 8d3f5e8549bb8f57968fd05363b0b04339b4694b Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 8 Oct 2026 16:16:57 -0700 Subject: [PATCH 5/7] Keep external agents visible in bars and clean their names like the web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - External agents and Unknown fill their bar segment with ▒ instead of the empty track's ░, so their share shows without colour. - Strip invisible format characters (zero-width space, BOM, soft hyphen, bidi controls) except the emoji joiner, matching the web and API. - Cap a self-reported name at 256 runes before measuring it; a huge name made truncation quadratic. - Re-key repo agent counts by agentKey, so names that clean alike are one legend entry and bar segment. - Tighten tests that passed without the behaviour they named. Co-Authored-By: Claude Opus 5.5 --- cmd/entire/cli/activity_render.go | 48 +++++++++++++---- cmd/entire/cli/activity_render_test.go | 71 ++++++++++++++++++++++++-- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index 096793230d..61cedb4429 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -122,7 +122,7 @@ var agentDisplayMap = map[string]agentDisplay{ 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: '░'}, + activityAgentUnknown: {Label: "Unknown", Color: palette.Muted, Char: externalAgentBarChar}, } // agentKey is the built-in agent ID for raw, or raw itself for an agent Entire @@ -137,16 +137,45 @@ func agentKey(raw string) string { return agentUnknown } -// externalAgentName is raw trimmed and without control or bidi characters. -// External agent names are self-reported, so escape sequences must not reach -// the terminal. +// 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 { - return strings.TrimSpace(strings.Map(func(r rune) rune { - if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) { + 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)) + }, 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. +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 out } // maxExternalAgentLabelWidth bounds an external agent's self-reported name so @@ -160,7 +189,7 @@ func agentDisplayFor(key string) agentDisplay { return d } if name := externalAgentName(key); name != "" { - return agentDisplay{Label: truncateDisplayWidth(name, maxExternalAgentLabelWidth, "…"), Color: palette.Muted, Char: '░'} + return agentDisplay{Label: truncateDisplayWidth(name, maxExternalAgentLabelWidth, "…"), Color: palette.Muted, Char: externalAgentBarChar} } return agentDisplayMap[activityAgentUnknown] } @@ -254,7 +283,7 @@ func renderDotChart(w io.Writer, sty activityStyles, hourly []hourlyPoint, repos total := 0 for _, r := range repos { total += r.Total - for agent, count := range r.Agents { + for agent, count := range agentCounts(r.Agents) { agentTotals[agent] += count } } @@ -448,6 +477,7 @@ func renderAgentBar(sty activityStyles, agents map[string]int, maxCount, barWidt var b strings.Builder + agents = agentCounts(agents) filled := 0 for _, id := range agentRenderOrder(agents) { count := agents[id] diff --git a/cmd/entire/cli/activity_render_test.go b/cmd/entire/cli/activity_render_test.go index 3209494b73..162a8b273f 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -6,6 +6,7 @@ import ( "slices" "strings" "testing" + "time" "charm.land/lipgloss/v2" "unicode/utf8" @@ -115,15 +116,50 @@ func TestAgentDisplayFor_TruncatesLongExternalName(t *testing.T) { 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": "Goose", "Antigravity": "Antigravity"} { - if got := agentDisplayFor(agentKey(raw)).Label; got != want { - t.Errorf("label for %q = %q, want %q", raw, got, want) + 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) } } } @@ -134,7 +170,7 @@ func TestRenderSessionRow_AgentLabel(t *testing.T) { agent *string want string }{ - "built-in": {strPtr("Claude Code"), "Claude Code"}, + "built-in": {strPtr("claude_code"), "Claude Code"}, "external": {strPtr("Grok Bot"), "Grok Bot"}, "missing": {nil, "Unknown"}, } { @@ -177,6 +213,33 @@ func TestRenderDotChart_LegendNamesExternalAgent(t *testing.T) { } } +// 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) + } +} + +// 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 From 730018523a83f350a4fdbb68b94e74e1b081c307 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Fri, 9 Oct 2026 10:48:04 -0700 Subject: [PATCH 6/7] Treat any casing of Unknown as unknown; merge external case variants The CLI records an unidentified agent as "Unknown" (agent.AgentTypeUnknown), which agentKey kept as an external agent beside the canonical unknown, so the legend and commit badges could show two Unknown entries. Fold any casing of it into unknown. External names that differ only in case ("Grok Bot", "grok bot") now merge under the most-used spelling, as entire-api merges them for the web charts. Also fix the stale activityAgentCounts comment and a misplaced import. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4GWEZZ6RBA0GWQRRRWWAKMP --- cmd/entire/cli/activity_render.go | 50 +++++++++++++++++++++----- cmd/entire/cli/activity_render_test.go | 35 +++++++++++++++++- cmd/entire/cli/activity_types.go | 5 +-- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index 61cedb4429..3f90cc1292 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -131,7 +131,9 @@ func agentKey(raw string) string { if id := normalizeAgentString(raw); id != agentUnknown { return id } - if name := externalAgentName(raw); name != "" { + // 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 @@ -169,12 +171,42 @@ func externalAgentName(raw string) string { } // agentCounts re-keys counts by agentKey, so raw names that clean to the same -// agent (or to a built-in) are counted once. +// 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 +// lowercase, so only external names are affected. +func mergeCaseVariants(counts map[string]int) map[string]int { + type variants struct { + total, bestCount int + best string + } + groups := make(map[string]*variants, len(counts)) + for key, count := range counts { + 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 + } + } + out := make(map[string]int, len(groups)) + for _, g := range groups { + out[g.best] = g.total + } return out } @@ -736,8 +768,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 @@ -745,13 +776,14 @@ func uniqueCommitAgents(c userCommit) []string { agents = []string{cp.Agent} } for _, a := range agents { - id := agentKey(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 162a8b273f..18600cc147 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" "time" + "unicode/utf8" "charm.land/lipgloss/v2" - "unicode/utf8" ) const activityTestAgentClaude = "claude" @@ -541,3 +541,36 @@ 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) + } + 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 5701166ef2..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 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. From b0c3b9e5f46d737255964875d2a5a75422c703f0 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Fri, 9 Oct 2026 13:27:30 -0700 Subject: [PATCH 7/7] Keep built-in agents out of case merging; merge legend spellings across repos Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4H5JXSGH8D7406RRF5E5M5A --- cmd/entire/cli/activity_render.go | 23 ++++++++++++++++------- cmd/entire/cli/activity_render_test.go | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index 3f90cc1292..b2e0fa60d3 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -183,15 +183,22 @@ func agentCounts(counts map[string]int) map[string]int { // 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 -// lowercase, so only external names are affected. +// 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 { @@ -203,9 +210,8 @@ func mergeCaseVariants(counts map[string]int) map[string]int { g.best, g.bestCount = key, count } } - out := make(map[string]int, len(groups)) for _, g := range groups { - out[g.best] = g.total + out[g.best] += g.total } return out } @@ -311,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 agentCounts(r.Agents) { - agentTotals[agent] += count + for agent, count := range r.Agents { + rawTotals[agent] += count } } + agentTotals := agentCounts(rawTotals) totalLabel := "" if total > 0 { diff --git a/cmd/entire/cli/activity_render_test.go b/cmd/entire/cli/activity_render_test.go index 18600cc147..f3011d53c4 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -225,6 +225,16 @@ func TestRenderDotChart_LegendMergesCleanedNames(t *testing.T) { 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 @@ -569,6 +579,10 @@ func TestAgentCounts_MergesExternalCaseVariants(t *testing.T) { 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)