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
97 changes: 97 additions & 0 deletions packages/claude-code-plugin/hooks/lib/buddy_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]],
Expand Down Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions packages/claude-code-plugin/hooks/test_buddy_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
render_session_start,
render_session_summary,
render_returning_session,
render_bar_chart,
get_buddy_config,
type_text,
_to_ascii,
Expand Down Expand Up @@ -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"])
Loading