Skip to content

command over server#3

Open
abhinavm24 wants to merge 1 commit into
aybidi:mainfrom
abhinavm24:command-over-server
Open

command over server#3
abhinavm24 wants to merge 1 commit into
aybidi:mainfrom
abhinavm24:command-over-server

Conversation

@abhinavm24
Copy link
Copy Markdown

No description provided.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-viz CLI (and python -m claude_code_visualizer) to generate and optionally open an offline HTML report.
  • Moved session parsing/aggregation logic into claude_code_visualizer.data and 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:
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
with open(filepath) as f:
with open(filepath, encoding="utf-8", errors="replace") as f:

Copilot uses AI. Check for mistakes.
Comment on lines +429 to +435
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';
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +484 to +485
meta.innerHTML = `
<span>${fmtDate(o.firstDate)} – ${fmtDate(o.lastDate)}, ${new Date(o.lastDate).getUTCFullYear()}</span>
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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>

Copilot uses AI. Check for mistakes.

if args.open:
import webbrowser
webbrowser.open(f"file://{output_path}")
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
webbrowser.open(f"file://{output_path}")
webbrowser.open(output_path.as_uri())

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,3 @@
from claude_code_visualizer.cli import main

main()
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__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.

Suggested change
main()
if __name__ == "__main__":
main()

Copilot uses AI. Check for mistakes.
Comment on lines +495 to +513
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>`;

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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>`;
}

Copilot uses AI. Check for mistakes.
Comment on lines +527 to +543
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>`;


Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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').

Suggested change
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>`;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +987 to +989
const maxCount = d3.max(data.heatmap, d => d.count);
const color = d3.scaleSequential()
.domain([0, maxCount])
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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])

Copilot uses AI. Check for mistakes.
Comment thread README.md
Comment on lines +43 to 50
### 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
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +30
# 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>")

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

</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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants