command over server#3
Conversation
There was a problem hiding this comment.
Pull request overview
This PR packages the project as an installable Python CLI that generates a single self-contained HTML report (data + D3 inlined) from local Claude Code history, removing the need to run a local server.
Changes:
- Added a
claude-vizCLI (andpython -m claude_code_visualizer) to generate and optionally open an offline HTML report. - Moved session parsing/aggregation logic into
claude_code_visualizer.dataand introduced an HTML renderer that inlines assets. - Added packaged HTML/D3 assets and updated packaging metadata/docs to support pip installation.
Reviewed changes
Copilot reviewed 8 out of 11 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/claude_code_visualizer/cli.py | New CLI entry point to generate and optionally open the report. |
| src/claude_code_visualizer/data.py | New data pipeline for aggregating Claude session history into a viz-ready dict. |
| src/claude_code_visualizer/renderer.py | New renderer that inlines D3 + JSON data into the HTML template. |
| src/claude_code_visualizer/assets/template.html | New self-contained visualization template with embedded JS/D3 hooks. |
| src/claude_code_visualizer/main.py | Module entry point for python -m claude_code_visualizer. |
| src/claude_code_visualizer/init.py | Package init + version. |
| pyproject.toml | Introduces packaging config, console script entry point, and asset inclusion. |
| README.md | Updates Quick Start to include pip install + CLI usage. |
| .gitignore | Ignores build/dist artifacts for packaging workflows. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| def parse_jsonl(filepath): | ||
| """Read a JSONL file, yielding parsed records.""" | ||
| with open(filepath) as f: |
There was a problem hiding this comment.
Files are opened without an explicit encoding. On Windows (or other non-UTF-8 locales) this can raise decode errors when reading Claude's JSON/JSONL (typically UTF-8). Open these files with encoding="utf-8" (and consider errors="replace" if you want to be resilient) for consistent cross-platform behavior.
| with open(filepath) as f: | |
| with open(filepath, encoding="utf-8", errors="replace") as f: |
| const tooltip = document.getElementById('tooltip'); | ||
| function showTip(html, evt) { | ||
| tooltip.innerHTML = html; | ||
| tooltip.classList.add('visible'); | ||
| tooltip.style.left = (evt.clientX + 14) + 'px'; | ||
| tooltip.style.top = (evt.clientY - 14) + 'px'; | ||
| } |
There was a problem hiding this comment.
showTip() assigns directly to tooltip.innerHTML, but the HTML passed in includes values derived from local data (e.g., project names, tool names, summaries). If any of those fields contain </> characters, this becomes an HTML injection/XSS vector in the generated report. Escape interpolated values (e.g., escapeHtml()), or build the tooltip with DOM nodes and textContent for user-provided strings while only using markup you control.
| meta.innerHTML = ` | ||
| <span>${fmtDate(o.firstDate)} – ${fmtDate(o.lastDate)}, ${new Date(o.lastDate).getUTCFullYear()}</span> |
There was a problem hiding this comment.
When o.lastDate is null/undefined, new Date(o.lastDate).getUTCFullYear() renders 1970, which is misleading. If dates can be missing (e.g., no parseable timestamps), guard this and render an em dash or omit the year.
| meta.innerHTML = ` | |
| <span>${fmtDate(o.firstDate)} – ${fmtDate(o.lastDate)}, ${new Date(o.lastDate).getUTCFullYear()}</span> | |
| const lastDateObj = o.lastDate ? new Date(o.lastDate) : null; | |
| const yearPart = (lastDateObj && !isNaN(lastDateObj.getTime())) | |
| ? `, ${lastDateObj.getUTCFullYear()}` | |
| : ''; | |
| meta.innerHTML = ` | |
| <span>${fmtDate(o.firstDate)} – ${fmtDate(o.lastDate)}${yearPart}</span> |
|
|
||
| if args.open: | ||
| import webbrowser | ||
| webbrowser.open(f"file://{output_path}") |
There was a problem hiding this comment.
Opening the generated HTML via webbrowser.open(f"file://{output_path}") can fail on paths with spaces/special characters and is not a correct file URI on Windows. Prefer webbrowser.open(output_path.as_uri()) (or webbrowser.open_new_tab(...)) so the path is properly encoded and portable.
| webbrowser.open(f"file://{output_path}") | |
| webbrowser.open(output_path.as_uri()) |
| @@ -0,0 +1,3 @@ | |||
| from claude_code_visualizer.cli import main | |||
|
|
|||
| main() | |||
There was a problem hiding this comment.
__main__.py calls main() at import time. If any tooling/imports load claude_code_visualizer.__main__ (tests, Sphinx, etc.), it will unexpectedly execute the CLI and potentially sys.exit. Wrap the call in an if __name__ == "__main__": guard to avoid side effects on import.
| main() | |
| if __name__ == "__main__": | |
| main() |
| const tl = data.timeline; | ||
| const busiest = tl.reduce((a, b) => a.messages > b.messages ? a : b); | ||
| const second = tl.filter(d => d.date !== busiest.date).reduce((a, b) => a.messages > b.messages ? a : b); | ||
| const totalDays = tl.length; | ||
|
|
||
| const firstDay = tl[0]; | ||
| const firstTwoMsgs = tl.slice(0, 2).reduce((s, d) => s + d.messages, 0); | ||
| document.getElementById('tl-step-0').innerHTML = `<p>it started on <span class="hl">${fmtDate(data.overview.firstDate)}</span>. | ||
| that first day, you sent <span class="hl">${fmtFull(firstDay.messages)} messages</span> across | ||
| <span class="hl">${firstDay.sessions} session${firstDay.sessions > 1 ? 's' : ''}</span>. something clicked.</p>`; | ||
| document.getElementById('tl-step-1').innerHTML = `<p>your busiest day was <span class="hl">${fmtDate(busiest.date)}</span> | ||
| with <span class="hl">${fmtFull(busiest.messages)} messages</span> across ${busiest.sessions} sessions. | ||
| ${second.messages > busiest.messages * 0.8 | ||
| ? `<span class="hl">${fmtDate(second.date)}</span> came close with ${fmtFull(second.messages)}.` | ||
| : `that's ${Math.round(busiest.messages / (data.overview.totalMessages / totalDays))}x your daily average.`}</p>`; | ||
| document.getElementById('tl-step-2').innerHTML = `<p>over ${data.overview.daySpan} days, a rhythm emerged. | ||
| <span class="hl">${totalDays} active days</span> of focused work, | ||
| with ${data.overview.daySpan - totalDays} quiet days in between — ${totalDays > data.overview.daySpan * 0.6 ? 'a steady cadence.' : 'intense bursts followed by rest.'}</p>`; | ||
|
|
There was a problem hiding this comment.
populateNarrative() assumes data.timeline has at least 2 entries. If no messages have parseable timestamps, data.timeline can be empty and reduce()/tl[0] will throw, breaking the page even though totalMessages > 0. Add guards for empty/single-element timelines (and consider displaying a friendly message when timestamps are missing).
| const tl = data.timeline; | |
| const busiest = tl.reduce((a, b) => a.messages > b.messages ? a : b); | |
| const second = tl.filter(d => d.date !== busiest.date).reduce((a, b) => a.messages > b.messages ? a : b); | |
| const totalDays = tl.length; | |
| const firstDay = tl[0]; | |
| const firstTwoMsgs = tl.slice(0, 2).reduce((s, d) => s + d.messages, 0); | |
| document.getElementById('tl-step-0').innerHTML = `<p>it started on <span class="hl">${fmtDate(data.overview.firstDate)}</span>. | |
| that first day, you sent <span class="hl">${fmtFull(firstDay.messages)} messages</span> across | |
| <span class="hl">${firstDay.sessions} session${firstDay.sessions > 1 ? 's' : ''}</span>. something clicked.</p>`; | |
| document.getElementById('tl-step-1').innerHTML = `<p>your busiest day was <span class="hl">${fmtDate(busiest.date)}</span> | |
| with <span class="hl">${fmtFull(busiest.messages)} messages</span> across ${busiest.sessions} sessions. | |
| ${second.messages > busiest.messages * 0.8 | |
| ? `<span class="hl">${fmtDate(second.date)}</span> came close with ${fmtFull(second.messages)}.` | |
| : `that's ${Math.round(busiest.messages / (data.overview.totalMessages / totalDays))}x your daily average.`}</p>`; | |
| document.getElementById('tl-step-2').innerHTML = `<p>over ${data.overview.daySpan} days, a rhythm emerged. | |
| <span class="hl">${totalDays} active days</span> of focused work, | |
| with ${data.overview.daySpan - totalDays} quiet days in between — ${totalDays > data.overview.daySpan * 0.6 ? 'a steady cadence.' : 'intense bursts followed by rest.'}</p>`; | |
| const tl = data.timeline || []; | |
| const tlStep0 = document.getElementById('tl-step-0'); | |
| const tlStep1 = document.getElementById('tl-step-1'); | |
| const tlStep2 = document.getElementById('tl-step-2'); | |
| // Handle case where no timeline entries could be built (e.g., missing/unparseable timestamps) | |
| if (tl.length === 0) { | |
| const o = data.overview; | |
| tlStep0.innerHTML = `<p>we couldn't parse timestamps for your messages, so we can't show a day-by-day timeline.</p>`; | |
| tlStep1.innerHTML = `<p>still, over <span class="hl">${o.daySpan} days</span> you traded | |
| <span class="hl">${fmtFull(o.totalMessages)} messages</span> with claude, | |
| spanning <span class="hl">${o.totalSessions} sessions</span>.</p>`; | |
| tlStep2.innerHTML = `<p>whenever you dropped in, you made it count — a story of focused, high-impact sessions.</p>`; | |
| } else { | |
| const busiest = tl.reduce((a, b) => a.messages > b.messages ? a : b); | |
| const totalDays = tl.length; | |
| const firstDay = tl[0]; | |
| let second = null; | |
| if (tl.length > 1) { | |
| const others = tl.filter(d => d.date !== busiest.date); | |
| if (others.length > 0) { | |
| second = others.reduce((a, b) => a.messages > b.messages ? a : b); | |
| } | |
| } | |
| tlStep0.innerHTML = `<p>it started on <span class="hl">${fmtDate(data.overview.firstDate)}</span>. | |
| that first day, you sent <span class="hl">${fmtFull(firstDay.messages)} messages</span> across | |
| <span class="hl">${firstDay.sessions} session${firstDay.sessions > 1 ? 's' : ''}</span>. something clicked.</p>`; | |
| const comparisonText = (second && second.messages > busiest.messages * 0.8) | |
| ? `<span class="hl">${fmtDate(second.date)}</span> came close with ${fmtFull(second.messages)}.` | |
| : `that's ${Math.round(busiest.messages / (data.overview.totalMessages / totalDays))}x your daily average.`; | |
| tlStep1.innerHTML = `<p>your busiest day was <span class="hl">${fmtDate(busiest.date)}</span> | |
| with <span class="hl">${fmtFull(busiest.messages)} messages</span> across ${busiest.sessions} sessions. | |
| ${comparisonText}</p>`; | |
| tlStep2.innerHTML = `<p>over ${data.overview.daySpan} days, a rhythm emerged. | |
| <span class="hl">${totalDays} active days</span> of focused work, | |
| with ${data.overview.daySpan - totalDays} quiet days in between — ${totalDays > data.overview.daySpan * 0.6 ? 'a steady cadence.' : 'intense bursts followed by rest.'}</p>`; | |
| } |
| const tools = data.tools; | ||
| const totalTools = tools.reduce((s, t) => s + t.count, 0); | ||
| document.getElementById('tl2-step-0').innerHTML = `<p>behind every conversation, claude used tools — | ||
| <span class="hl">${fmtFull(totalTools)} invocations</span> across | ||
| <span class="hl">${tools.length} different tools</span>.</p>`; | ||
| const toolPct = Math.round(tools[0].count / totalTools * 100); | ||
| document.getElementById('tl2-step-1').innerHTML = `<p><span class="hl preserve-case">${tools[0].name}</span> dominated | ||
| with <span class="hl">${fmtFull(tools[0].count)} uses</span> — ${toolPct}% of all tool calls.</p>`; | ||
| document.getElementById('tl2-step-2').innerHTML = `<p><span class="hl-pink preserve-case">${tools[1].name}</span> | ||
| comes next at ${fmtFull(tools[1].count)}${tools.length > 3 ? `, followed by | ||
| <span class="hl-green preserve-case">${tools[2].name}</span> (${fmtFull(tools[2].count)}) and <span class="hl-amber preserve-case">${tools[3].name}</span> (${fmtFull(tools[3].count)})` : ''}. | ||
| ${tools[0].name === 'Read' ? 'read first, act second — a deliberate workflow.' : | ||
| tools[0].name === 'Edit' ? 'editing was the primary mode — hands-on from the start.' : | ||
| tools[0].name === 'Bash' ? 'command-line heavy — claude spent most of its time executing.' : | ||
| 'a clear preference in how claude works with you.'}</p>`; | ||
|
|
||
|
|
There was a problem hiding this comment.
Tools narrative assumes data.tools is non-empty (uses tools[0]) and has at least 2 items (uses tools[1]). In sessions where Claude never emits tool_use blocks, data.tools can be empty and this will throw; additionally toolPct divides by totalTools which can be 0. Guard for tools.length === 0 / < 2 and avoid division by zero (e.g., show 'no tool calls recorded').
| const tools = data.tools; | |
| const totalTools = tools.reduce((s, t) => s + t.count, 0); | |
| document.getElementById('tl2-step-0').innerHTML = `<p>behind every conversation, claude used tools — | |
| <span class="hl">${fmtFull(totalTools)} invocations</span> across | |
| <span class="hl">${tools.length} different tools</span>.</p>`; | |
| const toolPct = Math.round(tools[0].count / totalTools * 100); | |
| document.getElementById('tl2-step-1').innerHTML = `<p><span class="hl preserve-case">${tools[0].name}</span> dominated | |
| with <span class="hl">${fmtFull(tools[0].count)} uses</span> — ${toolPct}% of all tool calls.</p>`; | |
| document.getElementById('tl2-step-2').innerHTML = `<p><span class="hl-pink preserve-case">${tools[1].name}</span> | |
| comes next at ${fmtFull(tools[1].count)}${tools.length > 3 ? `, followed by | |
| <span class="hl-green preserve-case">${tools[2].name}</span> (${fmtFull(tools[2].count)}) and <span class="hl-amber preserve-case">${tools[3].name}</span> (${fmtFull(tools[3].count)})` : ''}. | |
| ${tools[0].name === 'Read' ? 'read first, act second — a deliberate workflow.' : | |
| tools[0].name === 'Edit' ? 'editing was the primary mode — hands-on from the start.' : | |
| tools[0].name === 'Bash' ? 'command-line heavy — claude spent most of its time executing.' : | |
| 'a clear preference in how claude works with you.'}</p>`; | |
| const tools = Array.isArray(data.tools) ? data.tools : []; | |
| const totalTools = tools.reduce((s, t) => s + (t && typeof t.count === 'number' ? t.count : 0), 0); | |
| if (tools.length === 0 || totalTools === 0) { | |
| document.getElementById('tl2-step-0').innerHTML = `<p>behind every conversation, claude didn't use any tools — | |
| no tool calls were recorded in this dataset.</p>`; | |
| document.getElementById('tl2-step-1').innerHTML = ''; | |
| document.getElementById('tl2-step-2').innerHTML = ''; | |
| } else { | |
| document.getElementById('tl2-step-0').innerHTML = `<p>behind every conversation, claude used tools — | |
| <span class="hl">${fmtFull(totalTools)} invocations</span> across | |
| <span class="hl">${tools.length} different tools</span>.</p>`; | |
| const toolPct = Math.round(tools[0].count / totalTools * 100); | |
| document.getElementById('tl2-step-1').innerHTML = `<p><span class="hl preserve-case">${tools[0].name}</span> dominated | |
| with <span class="hl">${fmtFull(tools[0].count)} uses</span> — ${toolPct}% of all tool calls.</p>`; | |
| const preferenceText = | |
| tools[0].name === 'Read' ? 'read first, act second — a deliberate workflow.' : | |
| tools[0].name === 'Edit' ? 'editing was the primary mode — hands-on from the start.' : | |
| tools[0].name === 'Bash' ? 'command-line heavy — claude spent most of its time executing.' : | |
| 'a clear preference in how claude works with you.'; | |
| if (tools.length === 1) { | |
| document.getElementById('tl2-step-2').innerHTML = `<p>you relied exclusively on | |
| <span class="hl-pink preserve-case">${tools[0].name}</span> (${fmtFull(tools[0].count)} uses) — | |
| ${preferenceText}</p>`; | |
| } else { | |
| document.getElementById('tl2-step-2').innerHTML = `<p><span class="hl-pink preserve-case">${tools[1].name}</span> | |
| comes next at ${fmtFull(tools[1].count)}${tools.length > 3 ? `, followed by | |
| <span class="hl-green preserve-case">${tools[2].name}</span> (${fmtFull(tools[2].count)}) and <span class="hl-amber preserve-case">${tools[3].name}</span> (${fmtFull(tools[3].count)})` : ''}. | |
| ${preferenceText}</p>`; | |
| } | |
| } |
| const maxCount = d3.max(data.heatmap, d => d.count); | ||
| const color = d3.scaleSequential() | ||
| .domain([0, maxCount]) |
There was a problem hiding this comment.
d3.scaleSequential().domain([0, maxCount]) breaks down when maxCount is 0 (all heatmap counts are 0), because the scale's normalization divides by 0 and can yield NaN colors. Handle maxCount <= 0 by setting a non-degenerate domain (e.g., [0, 1]) or using a constant fill color when there's no activity.
| const maxCount = d3.max(data.heatmap, d => d.count); | |
| const color = d3.scaleSequential() | |
| .domain([0, maxCount]) | |
| const maxCount = d3.max(data.heatmap, d => d.count) || 0; | |
| const maxDomain = maxCount > 0 ? maxCount : 1; | |
| const color = d3.scaleSequential() | |
| .domain([0, maxDomain]) |
| ### Option B: Clone and run | ||
|
|
||
| ```bash | ||
| # 1. Clone | ||
| git clone git@github.com:aybidi/claude-code-visualizer.git | ||
| cd claude-code-visualizer | ||
|
|
||
| # 2. Generate your data (reads from ~/.claude/) | ||
| python3 process_data.py | ||
|
|
||
| # 3. Serve locally and open | ||
| python3 -m http.server 8765 | ||
| # Visit http://localhost:8765 |
There was a problem hiding this comment.
Quick Start "Option B" still instructs generating data.json via process_data.py and serving with http.server, which conflicts with the new "self-contained HTML" workflow (and the PR's stated goal of no server). Update Option B to use the new CLI/module entry point (e.g., editable install + claude-viz / python -m claude_code_visualizer) and remove the server step, or clearly label Option B as the legacy workflow.
| # Inline data — sanitize </script> sequences in JSON to prevent breaking the HTML | ||
| data_json = json.dumps(data, separators=(",", ":"), default=str) | ||
| data_json = data_json.replace("</script>", "<\\/script>") | ||
|
|
There was a problem hiding this comment.
</script> sanitization is case-sensitive, but HTML end tags are case-insensitive. If any string in the JSON contains </SCRIPT> (or variants like </script >), it can prematurely terminate the inline <script> block and allow HTML/JS injection or break the output. Use a case-insensitive replacement (ideally covering </script with optional whitespace before >), or escape all occurrences of </script regardless of case before embedding.
No description provided.