diff --git a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py index d6610e28..3595d2e9 100644 --- a/packages/claude-code-plugin/hooks/lib/buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/lib/buddy_renderer.py @@ -565,6 +565,96 @@ def _get_summary_header(key: str, language: str) -> str: return lang_headers.get(key, SUMMARY_HEADERS["en"].get(key, key)) +def render_bar_chart( + label: str, + value: int, + max_value: int, + width: int = 20, + color: str = "green", + ascii_mode: bool = False, +) -> str: + """Render a single mini bar chart line (#1041). + + Args: + label: Left-aligned label (e.g., "Edit", "Bash"). + value: Current value to visualize. + max_value: Maximum value for full bar width. + width: Bar width in characters (default 20). + color: ANSI color name for the filled portion. + ascii_mode: If True, use ASCII characters instead of Unicode. + + Returns: + Formatted bar chart line, e.g.: + Normal: " Edit ████████░░░░ 5" + ASCII: " Edit [####........] 5" + """ + if max_value <= 0 or value <= 0: + ratio = 0.0 + else: + ratio = min(value / max_value, 1.0) + + filled = int(ratio * width) + empty = width - filled + + if ascii_mode: + bar = "[" + "#" * filled + "." * empty + "]" + else: + filled_bar = "\u2588" * filled + "\u2591" * empty + bar = _colorize(filled_bar, color) + + return f" {label:<12} {bar} {value}" + + +def _render_bar_charts( + tool_names: Dict[str, int], + stats: Dict[str, Any], + ascii_mode: bool = False, +) -> List[str]: + """Build bar chart lines from tool usage data (#1041). + + Shows top tool distribution bars, normalized to the highest count. + + Args: + tool_names: Dict of tool name → usage count. + stats: Session stats dict with files_changed, tool_count. + ascii_mode: Whether to use ASCII rendering. + + Returns: + List of formatted bar chart lines. + """ + if not tool_names: + return [] + + # Get top 3 tools by usage + sorted_tools = sorted(tool_names.items(), key=lambda x: x[1], reverse=True) + top_tools = sorted_tools[:3] + + if not top_tools: + return [] + + max_val = top_tools[0][1] + if max_val <= 0: + return [] + + # Color mapping by tool type + tool_colors = { + "Edit": "yellow", + "Write": "yellow", + "Bash": "cyan", + "Read": "blue", + "Grep": "blue", + "Glob": "blue", + "Agent": "magenta", + } + + lines: List[str] = [] + for tool, count in top_tools: + color = tool_colors.get(tool, "green") + lines.append(render_bar_chart(tool, count, max_val, color=color, ascii_mode=ascii_mode)) + + return lines + + def render_session_summary( stats: Dict[str, Any], agents: List[Dict[str, Any]], @@ -638,6 +728,13 @@ def render_session_summary( if one_line: parts.append(f"\U0001f4ac {one_line}") + # Bar chart visualization (#1041) + ascii_mode = bc.get("asciiMode", False) + charts = _render_bar_charts(tool_names, stats, ascii_mode=ascii_mode) + if charts: + parts.append("") + parts.extend(charts) + # Active agents section if agents: header = _get_summary_header("agents", language) diff --git a/packages/claude-code-plugin/hooks/test_buddy_renderer.py b/packages/claude-code-plugin/hooks/test_buddy_renderer.py index 03edd860..e799b37a 100644 --- a/packages/claude-code-plugin/hooks/test_buddy_renderer.py +++ b/packages/claude-code-plugin/hooks/test_buddy_renderer.py @@ -16,6 +16,7 @@ render_session_start, render_session_summary, render_returning_session, + render_bar_chart, get_buddy_config, type_text, _to_ascii, @@ -704,6 +705,112 @@ def test_typing_mode_ascii(self, capsys, monkeypatch): assert "\u256d" not in captured.err +class TestRenderBarChart: + """Tests for render_bar_chart — mini bar chart visualization (#1041).""" + + def test_full_bar(self): + result = render_bar_chart("Edit", 10, 10, width=10) + assert "Edit" in result + assert "10" in result + assert "\u2588" * 10 in result # ██████████ + + def test_half_bar(self): + result = render_bar_chart("Bash", 5, 10, width=10) + assert "\u2588" * 5 in result + assert "\u2591" * 5 in result + + def test_zero_value(self): + result = render_bar_chart("Read", 0, 10, width=10) + assert "0" in result + assert "\u2591" * 10 in result # all empty + + def test_zero_max_value(self): + result = render_bar_chart("Read", 5, 0, width=10) + assert "5" in result + assert "\u2591" * 10 in result # all empty (safe division) + + def test_value_exceeds_max(self): + result = render_bar_chart("Edit", 15, 10, width=10) + assert "\u2588" * 10 in result # capped at full + + def test_ascii_mode(self): + result = render_bar_chart("Bash", 5, 10, width=10, ascii_mode=True) + assert "[" in result + assert "]" in result + assert "#" * 5 in result + assert "." * 5 in result + + def test_ascii_mode_full(self): + result = render_bar_chart("Edit", 10, 10, width=10, ascii_mode=True) + assert "[##########]" in result + + def test_ascii_mode_empty(self): + result = render_bar_chart("Read", 0, 10, width=10, ascii_mode=True) + assert "[..........]" in result + + def test_label_alignment(self): + result = render_bar_chart("X", 5, 10, width=10) + # Label should be left-padded to 12 chars + assert "X " in result + + def test_color_applied_in_normal_mode(self): + result = render_bar_chart("Edit", 5, 10, color="yellow") + assert ANSI_COLORS["yellow"] in result + assert ANSI_COLORS["reset"] in result + + def test_no_color_in_ascii_mode(self): + result = render_bar_chart("Edit", 5, 10, color="yellow", ascii_mode=True) + assert ANSI_COLORS["yellow"] not in result + + +class TestBarChartInSummary: + """Tests for bar chart integration in render_session_summary (#1041).""" + + def test_bar_charts_appear_with_tool_names(self): + stats = {"duration_minutes": 10, "tool_count": 30, "files_changed": 5} + tool_names = {"Edit": 10, "Read": 15, "Bash": 5} + result = render_session_summary(stats, [], "casual", "en", tool_names=tool_names) + assert "\u2588" in result # bar chart present + assert "Edit" in result + assert "Read" in result + assert "Bash" in result + + def test_bar_charts_show_top_3_only(self): + stats = {"duration_minutes": 10, "tool_count": 50, "files_changed": 5} + tool_names = {"Edit": 10, "Read": 20, "Bash": 15, "Glob": 3, "Grep": 2} + result = render_session_summary(stats, [], "casual", "en", tool_names=tool_names) + # Top 3: Read(20), Bash(15), Edit(10) + assert "Read" in result + assert "Bash" in result + assert "Edit" in result + + def test_no_bar_charts_without_tool_names(self): + stats = {"duration_minutes": 10, "tool_count": 5, "files_changed": 2} + result = render_session_summary(stats, [], "casual", "en") + assert "\u2588" not in result # no bar chars + + def test_bar_charts_ascii_mode(self): + bc = { + "name": "Buddy", "face": BUDDY_FACE, + "greeting": "", "farewell": "", "asciiMode": True, + } + stats = {"duration_minutes": 10, "tool_count": 20, "files_changed": 3} + tool_names = {"Edit": 8, "Read": 12} + result = render_session_summary( + stats, [], "casual", "en", + buddy_config=bc, tool_names=tool_names, + ) + assert "[#" in result # ASCII bar + assert "\u2588" not in result # no Unicode bar + + def test_empty_tool_names_no_charts(self): + stats = {"duration_minutes": 5, "tool_count": 0, "files_changed": 0} + result = render_session_summary( + stats, [], "casual", "en", tool_names={}, + ) + assert "\u2588" not in result + + if __name__ == "__main__": import pytest pytest.main([__file__, "-v"])