diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index eab0391f67..eac6e3919c 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -352,11 +352,9 @@ func renderRepoChart(w io.Writer, sty activityStyles, repos []repoContribution) } for _, r := range display { - name := r.Repo - if len(name) > maxNameLen { - name = name[:maxNameLen-1] + "…" - } - name = fmt.Sprintf("%-*s", maxNameLen, name) + // padOrTruncate is rune-aware; truncating r.Repo with a byte slice + // could split a multi-byte rune and emit invalid UTF-8. + name := padOrTruncate(r.Repo, maxNameLen) bar := renderAgentBar(sty, r.Agents, maxCount, barWidth) count := fmt.Sprintf("%*d", countWidth, r.Total) diff --git a/cmd/entire/cli/activity_render_test.go b/cmd/entire/cli/activity_render_test.go index 9b34ec8ad5..6b2978ea38 100644 --- a/cmd/entire/cli/activity_render_test.go +++ b/cmd/entire/cli/activity_render_test.go @@ -276,6 +276,31 @@ func TestRenderRepoChart_LimitsToFive(t *testing.T) { } } +func TestRenderRepoChart_UnicodeNameSafeTruncation(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + sty := activityStyles{width: 60} + // A repo name long enough to force truncation and full of multi-byte + // runes, so a byte-based slice would split a rune and emit invalid UTF-8. + repos := []repoContribution{ + { + Repo: strings.Repeat("é", 40), + Total: 3, + Agents: map[string]int{activityTestAgentClaude: 3}, + }, + } + + renderRepoChart(&buf, sty, repos) + out := buf.String() + + if !utf8.ValidString(out) { + t.Fatal("rendered repo chart contains invalid UTF-8") + } + if !strings.Contains(out, "…") { + t.Error("expected the long repo name to be truncated with an ellipsis") + } +} + func TestPadOrTruncate(t *testing.T) { t.Parallel() tests := []struct {