Skip to content

Commit 41f26c5

Browse files
authored
feat(v0.8): tool-calling bar chart on session + project pages (#65) (#75)
New module `llmwiki/viz_tools.py` (stdlib-only, pure-SVG) — reads `tool_counts` from frontmatter (#63), renders a horizontal bar chart sorted descending with category-based coloring, top-10 truncation, and an "Other (N tools)" overflow row. Category palette (CSS custom properties with hex fallbacks): io Read/Write/Edit/NotebookEdit blue search Grep/Glob/WebSearch/ToolSearch purple exec Bash/Skill/TaskOutput orange network WebFetch/mcp__* green plan Agent/TodoWrite/ExitPlanMode slate other anything else gray Wired into build.py: - render_session injects a per-session chart just above the body - render_project_page adds an aggregate chart (summed across every session meta in the group) between the heatmap and the breadcrumbs - New CSS for .tool-chart-card + dark-mode `--tool-cat-*` overrides - Print styles hide .tool-chart-card Tests in tests/test_viz_tools.py — 38 new: - parse_tool_counts: JSON string, dict-direct, empty, missing, malformed - aggregate_tool_counts: sum across sessions, skip zero counts - category mapping (parametrized over all 16 standard tools) - SVG root shape + a11y label - sort descending + overflow row at max_bars=3 - tooltip grammar (singular vs plural, percentage) - long-name shortening (28 char cap) with full name in tooltip - bar-width scaling (largest=300px, half-count=~150px) - XSS defense on tool names - render_session_tool_chart / render_project_tool_chart wrappers 247 tests passing (was 209). Verified on real sessions: the long-running `clever-munching-parnas` session renders 11 bars (10 top tools + "Other (5 tools)"), with Bash dominating at 180 calls (36.1%) — the bar width fills the full 300px slot and progressively scales down.
1 parent 378753d commit 41f26c5

4 files changed

Lines changed: 518 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Versions below 1.0 are pre-production — API and file formats may change.
1010

1111
### Added
1212

13+
- **Tool-calling bar chart** (#65) — new `llmwiki/viz_tools.py` module (stdlib-only, pure-SVG) renders a horizontal bar chart of tool usage from the `tool_counts` frontmatter key (#63). One chart per session page (sorted descending, top 10 tools + "Other (N tools)" overflow row) and one aggregate chart per project page. Category-based coloring: I/O (Read/Write/Edit — blue), Search (Grep/Glob/WebSearch — purple), Execution (Bash/Skill — orange), Network (WebFetch/mcp__* — green), Planning (Agent/TodoWrite/ExitPlanMode — slate). Tooltips show `{tool}: {count} calls ({pct}%)`. Long tool names (e.g. `mcp__Claude_in_Chrome__tabs_context_mcp`) are truncated to 28 chars in the label column but preserved in the tooltip. Dark-mode variants for every category via `--tool-cat-*` CSS custom properties. 38 new tests cover: JSON-string and dict frontmatter shapes, aggregation across sessions, category mapping for every standard + MCP tool, sort order, overflow collapse, tooltip grammar, XSS defense, bar-width scaling.
1314
- **GitLab/GitHub-style 365-day activity heatmap** (#64, #72) — new `llmwiki/viz_heatmap.py` module (stdlib-only, pure-SVG) renders a full-year rolling contribution grid at build time. One aggregate heatmap on `site/index.html` counting every main session across all projects, one per-project heatmap on each `projects/<slug>.html` page scoped to that project. Sunday-aligned 53-column grid (369–371 cells depending on where the build date lands in the week), five-level quantile bucketing computed over *non-zero* days so sparse projects don't collapse into a single color, empty days always render as level-0 so the grid dimensions stay constant. Dark-mode variant via `--heatmap-0..4` CSS custom properties (light: ebedf0→216e39, dark: 161b22→39d353). A11y: `role="img"` + descriptive `aria-label`, per-cell `<title>` tooltips. Replaces the v0.4 JS-based tiny-strip heatmap. 22 new tests lock the window bounds, quantile math, SVG structure, and sparse-data edge cases.
1415
- **Session metrics frontmatter** (#63) — converter now emits five new keys per session as JSON inline: `tool_counts`, `token_totals` (input / cache_creation / cache_read / output), `turn_count`, `hour_buckets` (UTC-normalised ISO-hour → activity count), and `duration_seconds`. Foundation for the v0.8 visualization stack (#64 heatmap / #65 tool chart / #66 token card). Stdlib-only; byte-identical on re-run. 24 new tests.
1516
- **Changelog page** (#72) — `CHANGELOG.md` now renders as a first-class page at `site/changelog.html` with a nav-bar link, narrow reading column, keep-a-changelog typography, and the same theme/print styles as the rest of the wiki.

llmwiki/build.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@
4343
from llmwiki import REPO_ROOT
4444
from llmwiki.freshness import freshness_badge, load_freshness_config
4545
from llmwiki.viz_heatmap import collect_session_counts, render_heatmap
46+
from llmwiki.viz_tools import (
47+
render_project_tool_chart,
48+
render_session_tool_chart,
49+
)
4650

4751
# ─── paths ─────────────────────────────────────────────────────────────────
4852

@@ -591,6 +595,19 @@ def render_session(
591595
preview += f", +{len(tools_list) - 6} more"
592596
tools_preview = f'<div class="meta-tools muted">tools: {html.escape(preview)}</div>'
593597

598+
# v0.8 (#65): horizontal bar chart of tool calls in this session.
599+
# Uses the `tool_counts` JSON dict from frontmatter (#63). Empty
600+
# sessions (no recorded calls) render nothing.
601+
tool_chart_svg = render_session_tool_chart(meta)
602+
tool_chart_block = ""
603+
if tool_chart_svg:
604+
tool_chart_block = (
605+
'<div class="tool-chart-card">'
606+
'<div class="tool-chart-label muted">Tool calls</div>'
607+
f'{tool_chart_svg}'
608+
'</div>'
609+
)
610+
594611
# IMPORTANT: The HTML file is named `<path.stem>.html` (e.g. date-slug),
595612
# NOT `<slug>.html`. The siblings + canonical must use path.stem.
596613
html_stem = path.stem
@@ -630,7 +647,7 @@ def render_session(
630647
)
631648
+ nav_bar("sessions", link_prefix="../../")
632649
+ hero(str(title_raw), meta_strip, size="hero-sm", subtitle_is_html=True)
633-
+ f'<section class="section">\n <div class="container">\n{breadcrumbs}\n{tools_preview}\n{actions_html}\n <article class="content" itemscope itemtype="https://schema.org/Article">\n'
650+
+ f'<section class="section">\n <div class="container">\n{breadcrumbs}\n{tools_preview}\n{actions_html}\n{tool_chart_block}\n <article class="content" itemscope itemtype="https://schema.org/Article">\n'
634651
+ f'<meta itemprop="headline" content="{html.escape(str(title_raw))}">\n'
635652
+ f'<meta itemprop="datePublished" content="{html.escape(str(meta.get("started") or date))}">\n'
636653
+ f'<meta itemprop="inLanguage" content="en">\n'
@@ -702,7 +719,22 @@ def card(p: Path, meta: dict[str, Any]) -> str:
702719
</div>
703720
</section>"""
704721

722+
# v0.8 (#65): aggregate tool-call bar chart across all sessions in
723+
# this project. Projects with no recorded tool calls render nothing.
724+
proj_tool_chart = render_project_tool_chart(proj_entries, project_slug)
725+
tool_chart_block = ""
726+
if proj_tool_chart:
727+
tool_chart_block = f"""<section class="section tool-chart-section">
728+
<div class="container">
729+
<div class="tool-chart-card">
730+
<div class="tool-chart-label muted">Tool calls · {html.escape(project_slug)} aggregate</div>
731+
{proj_tool_chart}
732+
</div>
733+
</div>
734+
</section>"""
735+
705736
body = f"""{heatmap_block}
737+
{tool_chart_block}
706738
<section class="section">
707739
<div class="container">
708740
{crumbs}
@@ -1414,6 +1446,33 @@ def build_search_index(
14141446
.heatmap-svg rect { transition: stroke 0.1s; }
14151447
.heatmap-svg rect:hover { stroke: var(--accent); stroke-width: 1; }
14161448
1449+
/* v0.8 (#65): Tool-call bar chart — rendered as pure SVG by
1450+
llmwiki/viz_tools.py. CSS custom properties drive the category
1451+
colors so the page theme can override them; dark-mode variants
1452+
use saturated fills that read against the dark card background. */
1453+
:root {
1454+
--tool-cat-io: #3b82f6;
1455+
--tool-cat-search: #a855f7;
1456+
--tool-cat-exec: #f97316;
1457+
--tool-cat-network: #10b981;
1458+
--tool-cat-plan: #64748b;
1459+
--tool-cat-other: #9ca3af;
1460+
}
1461+
:root[data-theme="dark"] {
1462+
--tool-cat-io: #60a5fa;
1463+
--tool-cat-search: #c084fc;
1464+
--tool-cat-exec: #fb923c;
1465+
--tool-cat-network: #34d399;
1466+
--tool-cat-plan: #94a3b8;
1467+
--tool-cat-other: #9ca3af;
1468+
}
1469+
.tool-chart-section { padding-top: 0; padding-bottom: 12px; }
1470+
.tool-chart-card { margin: 16px 0 24px; padding: 14px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; }
1471+
.tool-chart-label { font-size: 0.78rem; margin-bottom: 8px; }
1472+
.tool-chart-svg { display: block; max-width: 100%; }
1473+
.tool-chart-svg rect { transition: opacity 0.1s; }
1474+
.tool-chart-svg rect:hover { opacity: 0.85; }
1475+
14171476
/* v0.4: Deep-link icon next to headings */
14181477
.content h2 .deep-link, .content h3 .deep-link, .content h4 .deep-link { margin-left: 8px; font-size: 0.8em; opacity: 0; text-decoration: none; transition: opacity 0.15s; }
14191478
.content h2:hover .deep-link, .content h3:hover .deep-link, .content h4:hover .deep-link { opacity: 0.7; }
@@ -1481,7 +1540,7 @@ def build_search_index(
14811540
.nav, .footer, .palette, .help-dialog, .session-actions, .filter-bar,
14821541
.progress-bar, .nav-search-btn, .theme-toggle, .copy-code-btn,
14831542
.wikilink-preview, .timeline-block, .toc-sidebar, .mobile-bottom-nav,
1484-
.related-pages, .activity-heatmap, .deep-link, .breadcrumbs,
1543+
.related-pages, .activity-heatmap, .tool-chart-card, .deep-link, .breadcrumbs,
14851544
.meta-tools { display: none !important; }
14861545
body { background: #fff; color: #000; font-size: 11pt; padding-bottom: 0; }
14871546
.hero { padding: 12px 0 8px; background: #fff; border: none; }

llmwiki/viz_tools.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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

Comments
 (0)