|
| 1 | +"""Tool-calling bar chart (v0.8 — closes #65). |
| 2 | +
|
| 3 | +Pure-SVG horizontal bar chart, stdlib-only. Consumes the `tool_counts` |
| 4 | +dict that the converter writes into session frontmatter (#63) and renders |
| 5 | +a compact "Tools used in this session" card. |
| 6 | +
|
| 7 | +Design notes: |
| 8 | +
|
| 9 | +* **Horizontal** — tool names are long ("mcp__Claude_Preview__preview_start") |
| 10 | + and horizontal bars give the labels room without wrapping. |
| 11 | +* **Descending sort** — the tool that dominates a session should be the |
| 12 | + first thing you see. A "Other (N tools)" row summarises overflow. |
| 13 | +* **Category palette** — each tool bucket gets a deliberate color so the |
| 14 | + eye can tell I/O vs search vs execution vs network vs planning at a |
| 15 | + glance. Full list at the top of the module — easy to extend when new |
| 16 | + MCP tools appear in real transcripts. |
| 17 | +* **No grid lines, no axis ticks** — only the bar label + count, per |
| 18 | + Tufte. Absolute counts go on each bar; percentages go in the tooltip. |
| 19 | +* **CSS custom properties** drive the final colors so the page theme |
| 20 | + can override via `--tool-cat-*` vars. Fallback hex lives in an inline |
| 21 | + `<style>` block in the SVG. |
| 22 | +* Up to `max_bars` visible bars (default 10). If there are more tools, |
| 23 | + the rest collapse into a single "Other (N tools)" row so the chart |
| 24 | + stays readable. |
| 25 | +""" |
| 26 | + |
| 27 | +from __future__ import annotations |
| 28 | + |
| 29 | +import html |
| 30 | +import json |
| 31 | +from typing import Mapping, Optional |
| 32 | + |
| 33 | +# ─── layout constants ──────────────────────────────────────────────────── |
| 34 | + |
| 35 | +BAR_HEIGHT = 18 |
| 36 | +BAR_GAP = 6 |
| 37 | +LABEL_WIDTH = 170 # room for tool name on the left |
| 38 | +COUNT_WIDTH = 52 # room for the count text on the right |
| 39 | +BAR_MAX_WIDTH = 300 |
| 40 | +LEFT_PAD = 6 |
| 41 | +RIGHT_PAD = 6 |
| 42 | +TOP_PAD = 6 |
| 43 | + |
| 44 | + |
| 45 | +# ─── category palette ──────────────────────────────────────────────────── |
| 46 | + |
| 47 | +# Ordered: first match wins. Prefix match (tool name startswith(pattern)) |
| 48 | +# or exact match. |
| 49 | +_TOOL_CATEGORIES: list[tuple[str, tuple[str, ...]]] = [ |
| 50 | + # (category_name, (patterns…)) |
| 51 | + ("io", ( |
| 52 | + "Read", "Write", "Edit", "NotebookEdit", "MultiEdit", |
| 53 | + )), |
| 54 | + ("search", ( |
| 55 | + "Grep", "Glob", "WebSearch", "ToolSearch", |
| 56 | + )), |
| 57 | + ("exec", ( |
| 58 | + "Bash", "TaskOutput", "TaskStop", "Skill", |
| 59 | + )), |
| 60 | + ("network", ( |
| 61 | + "WebFetch", "mcp__", |
| 62 | + )), |
| 63 | + ("plan", ( |
| 64 | + "Agent", "AskUserQuestion", "TodoWrite", "ExitPlanMode", |
| 65 | + "CronCreate", "CronDelete", "CronList", "EnterPlanMode", |
| 66 | + "EnterWorktree", "ExitWorktree", |
| 67 | + )), |
| 68 | +] |
| 69 | + |
| 70 | +_CATEGORY_PALETTE = { |
| 71 | + "io": "#3b82f6", # blue |
| 72 | + "search": "#a855f7", # purple |
| 73 | + "exec": "#f97316", # orange |
| 74 | + "network": "#10b981", # green |
| 75 | + "plan": "#64748b", # slate |
| 76 | + "other": "#9ca3af", # gray |
| 77 | +} |
| 78 | + |
| 79 | + |
| 80 | +def _category_for(tool_name: str) -> str: |
| 81 | + """Return the category key for a given tool name. Defaults to 'other'.""" |
| 82 | + for cat, patterns in _TOOL_CATEGORIES: |
| 83 | + for p in patterns: |
| 84 | + if tool_name == p or tool_name.startswith(p): |
| 85 | + return cat |
| 86 | + return "other" |
| 87 | + |
| 88 | + |
| 89 | +# ─── data collection ───────────────────────────────────────────────────── |
| 90 | + |
| 91 | + |
| 92 | +def parse_tool_counts(meta: Mapping[str, object]) -> dict[str, int]: |
| 93 | + """Pull `tool_counts` out of a session meta dict. |
| 94 | +
|
| 95 | + The converter (#63) serializes `tool_counts` as an inline JSON object |
| 96 | + in frontmatter; the llmwiki frontmatter parser stores it as a plain |
| 97 | + string, so we `json.loads` it here. Missing / malformed values return |
| 98 | + an empty dict so callers don't have to null-check. |
| 99 | + """ |
| 100 | + raw = meta.get("tool_counts") |
| 101 | + if raw is None or raw == "": |
| 102 | + return {} |
| 103 | + if isinstance(raw, dict): |
| 104 | + return {str(k): int(v) for k, v in raw.items() if isinstance(v, (int, float))} |
| 105 | + if isinstance(raw, str): |
| 106 | + try: |
| 107 | + data = json.loads(raw) |
| 108 | + except (ValueError, json.JSONDecodeError): |
| 109 | + return {} |
| 110 | + if isinstance(data, dict): |
| 111 | + return {str(k): int(v) for k, v in data.items() |
| 112 | + if isinstance(v, (int, float))} |
| 113 | + return {} |
| 114 | + |
| 115 | + |
| 116 | +def aggregate_tool_counts(metas: list[Mapping[str, object]]) -> dict[str, int]: |
| 117 | + """Sum `tool_counts` across many sessions. |
| 118 | +
|
| 119 | + Used to build a project-level aggregate by feeding in every session's |
| 120 | + meta dict from the group. Tool names are preserved verbatim (including |
| 121 | + namespace prefixes like `mcp__Claude_in_Chrome__*`) so the categorisation |
| 122 | + can still differentiate them. |
| 123 | + """ |
| 124 | + out: dict[str, int] = {} |
| 125 | + for meta in metas: |
| 126 | + for name, count in parse_tool_counts(meta).items(): |
| 127 | + if count <= 0: |
| 128 | + continue |
| 129 | + out[name] = out.get(name, 0) + count |
| 130 | + return out |
| 131 | + |
| 132 | + |
| 133 | +# ─── SVG render ────────────────────────────────────────────────────────── |
| 134 | + |
| 135 | + |
| 136 | +def render_tool_chart( |
| 137 | + counts: dict[str, int], |
| 138 | + max_bars: int = 10, |
| 139 | + title: Optional[str] = None, |
| 140 | +) -> str: |
| 141 | + """Render a horizontal bar chart as a self-contained `<svg>`. |
| 142 | +
|
| 143 | + Empty input returns an empty string so callers can `if chart:` gate |
| 144 | + on whether to render the surrounding card at all. The returned SVG |
| 145 | + uses CSS custom properties `--tool-cat-io`, `--tool-cat-search`, etc. |
| 146 | + with hex fallbacks so it degrades gracefully outside a styled host. |
| 147 | + """ |
| 148 | + # Filter out zero counts and sort descending. |
| 149 | + items = [(name, c) for name, c in counts.items() if c > 0] |
| 150 | + if not items: |
| 151 | + return "" |
| 152 | + items.sort(key=lambda x: (-x[1], x[0])) |
| 153 | + |
| 154 | + total = sum(c for _, c in items) |
| 155 | + |
| 156 | + # Overflow collapse |
| 157 | + visible = items[:max_bars] |
| 158 | + hidden_count = len(items) - len(visible) |
| 159 | + if hidden_count > 0: |
| 160 | + hidden_total = sum(c for _, c in items[max_bars:]) |
| 161 | + visible.append((f"Other ({hidden_count} tools)", hidden_total)) |
| 162 | + |
| 163 | + max_count = max(c for _, c in visible) |
| 164 | + n_bars = len(visible) |
| 165 | + inner_h = n_bars * BAR_HEIGHT + (n_bars - 1) * BAR_GAP |
| 166 | + total_w = LEFT_PAD + LABEL_WIDTH + BAR_MAX_WIDTH + COUNT_WIDTH + RIGHT_PAD |
| 167 | + total_h = TOP_PAD * 2 + inner_h |
| 168 | + |
| 169 | + label = title or "Tool calls by tool" |
| 170 | + |
| 171 | + parts: list[str] = [ |
| 172 | + f'<svg class="tool-chart-svg" xmlns="http://www.w3.org/2000/svg" ' |
| 173 | + f'viewBox="0 0 {total_w} {total_h}" ' |
| 174 | + f'width="{total_w}" height="{total_h}" ' |
| 175 | + f'role="img" aria-label="{html.escape(label)}">', |
| 176 | + '<style>', |
| 177 | + '.tool-chart-svg text { font: 11px system-ui, -apple-system, sans-serif; fill: var(--text, #0f172a); }', |
| 178 | + '.tool-chart-svg .label-text { font-weight: 500; text-anchor: end; }', |
| 179 | + '.tool-chart-svg .count-text { fill: var(--text-secondary, #475569); }', |
| 180 | + ] |
| 181 | + for cat, fallback in _CATEGORY_PALETTE.items(): |
| 182 | + parts.append( |
| 183 | + f'.tool-chart-svg .cat-{cat} {{ fill: var(--tool-cat-{cat}, {fallback}); }}' |
| 184 | + ) |
| 185 | + parts.append('</style>') |
| 186 | + |
| 187 | + for idx, (name, count) in enumerate(visible): |
| 188 | + y = TOP_PAD + idx * (BAR_HEIGHT + BAR_GAP) |
| 189 | + # Label on the left, right-aligned |
| 190 | + label_x = LEFT_PAD + LABEL_WIDTH - 6 |
| 191 | + parts.append( |
| 192 | + f'<text class="label-text" x="{label_x}" y="{y + BAR_HEIGHT - 5}">' |
| 193 | + f'{html.escape(_shorten(name))}</text>' |
| 194 | + ) |
| 195 | + # Bar |
| 196 | + bar_x = LEFT_PAD + LABEL_WIDTH |
| 197 | + bar_w = max(1, round(count / max_count * BAR_MAX_WIDTH)) |
| 198 | + category = ( |
| 199 | + "other" if name.startswith("Other (") else _category_for(name) |
| 200 | + ) |
| 201 | + pct = 100.0 * count / total if total > 0 else 0.0 |
| 202 | + tooltip = f"{name}: {count} call{'s' if count != 1 else ''} ({pct:.1f}%)" |
| 203 | + parts.append( |
| 204 | + f'<rect class="cat-{category}" x="{bar_x}" y="{y}" ' |
| 205 | + f'width="{bar_w}" height="{BAR_HEIGHT}" rx="2" ry="2">' |
| 206 | + f'<title>{html.escape(tooltip)}</title>' |
| 207 | + f'</rect>' |
| 208 | + ) |
| 209 | + # Count text to the right of the bar |
| 210 | + count_x = bar_x + bar_w + 6 |
| 211 | + parts.append( |
| 212 | + f'<text class="count-text" x="{count_x}" y="{y + BAR_HEIGHT - 5}">' |
| 213 | + f'{count}</text>' |
| 214 | + ) |
| 215 | + |
| 216 | + parts.append('</svg>') |
| 217 | + return "\n".join(parts) |
| 218 | + |
| 219 | + |
| 220 | +def _shorten(name: str, max_len: int = 28) -> str: |
| 221 | + """Trim long tool names so they fit in the label column.""" |
| 222 | + if len(name) <= max_len: |
| 223 | + return name |
| 224 | + return name[: max_len - 1] + "…" |
| 225 | + |
| 226 | + |
| 227 | +# ─── convenience: all-in-one render helpers ───────────────────────────── |
| 228 | + |
| 229 | + |
| 230 | +def render_session_tool_chart(meta: Mapping[str, object]) -> str: |
| 231 | + """Render a tool chart for a single session. Returns empty string if |
| 232 | + the session has no recorded tool calls.""" |
| 233 | + return render_tool_chart(parse_tool_counts(meta), title="Tool calls in this session") |
| 234 | + |
| 235 | + |
| 236 | +def render_project_tool_chart(metas: list[Mapping[str, object]], project_slug: str) -> str: |
| 237 | + """Render an aggregate tool chart for all sessions in a project.""" |
| 238 | + return render_tool_chart( |
| 239 | + aggregate_tool_counts(metas), |
| 240 | + title=f"Tool calls across all {project_slug} sessions", |
| 241 | + ) |
0 commit comments