Skip to content

Commit 7242cd2

Browse files
ShlomoSteptclaude
andcommitted
Add copy buttons to code blocks and tool results
- Add copy button CSS with hover reveal effect - Add JavaScript to dynamically add copy buttons to pre, tool-result, and bash-command elements - Copy button shows "Copied!" feedback for 2 seconds after successful copy - Uses navigator.clipboard API for modern clipboard access - Buttons appear on hover and fade in smoothly This improves the UX for users who want to copy code snippets, terminal output, or tool results from transcripts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1c457cc commit 7242cd2

6 files changed

Lines changed: 198 additions & 0 deletions

src/claude_code_transcripts/__init__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,11 @@ def render_message(log_type, message_json, timestamp):
10501050
.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); }
10511051
.expand-btn:hover { background: rgba(0,0,0,0.1); }
10521052
.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; }
1053+
.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; }
1054+
.copy-btn:hover { background: white; color: var(--text-color); }
1055+
.copy-btn.copied { background: #c8e6c9; color: #2e7d32; }
1056+
pre:hover .copy-btn, .tool-result:hover .copy-btn, .truncatable:hover .copy-btn { opacity: 1; }
1057+
.code-container { position: relative; }
10531058
.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; }
10541059
.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; }
10551060
.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); }
@@ -1133,6 +1138,33 @@ def render_message(log_type, message_json, timestamp):
11331138
});
11341139
}
11351140
});
1141+
// Add copy buttons to pre elements and tool results
1142+
document.querySelectorAll('pre, .tool-result .truncatable-content, .bash-command').forEach(function(el) {
1143+
// Skip if already has a copy button
1144+
if (el.querySelector('.copy-btn')) return;
1145+
// Make container relative if needed
1146+
if (getComputedStyle(el).position === 'static') {
1147+
el.style.position = 'relative';
1148+
}
1149+
const copyBtn = document.createElement('button');
1150+
copyBtn.className = 'copy-btn';
1151+
copyBtn.textContent = 'Copy';
1152+
copyBtn.addEventListener('click', function(e) {
1153+
e.stopPropagation();
1154+
const textToCopy = el.textContent.replace(/^Copy$/, '').trim();
1155+
navigator.clipboard.writeText(textToCopy).then(function() {
1156+
copyBtn.textContent = 'Copied!';
1157+
copyBtn.classList.add('copied');
1158+
setTimeout(function() {
1159+
copyBtn.textContent = 'Copy';
1160+
copyBtn.classList.remove('copied');
1161+
}, 2000);
1162+
}).catch(function(err) {
1163+
console.error('Failed to copy:', err);
1164+
});
1165+
});
1166+
el.appendChild(copyBtn);
1167+
});
11361168
"""
11371169

11381170
# JavaScript to fix relative URLs when served via gistpreview.github.io

tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_index_html.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@
8989
.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); }
9090
.expand-btn:hover { background: rgba(0,0,0,0.1); }
9191
.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; }
92+
.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; }
93+
.copy-btn:hover { background: white; color: var(--text-color); }
94+
.copy-btn.copied { background: #c8e6c9; color: #2e7d32; }
95+
pre:hover .copy-btn, .tool-result:hover .copy-btn, .truncatable:hover .copy-btn { opacity: 1; }
96+
.code-container { position: relative; }
9297
.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; }
9398
.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; }
9499
.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); }
@@ -509,6 +514,33 @@ <h1>Claude Code transcript</h1>
509514
});
510515
}
511516
});
517+
// Add copy buttons to pre elements and tool results
518+
document.querySelectorAll('pre, .tool-result .truncatable-content, .bash-command').forEach(function(el) {
519+
// Skip if already has a copy button
520+
if (el.querySelector('.copy-btn')) return;
521+
// Make container relative if needed
522+
if (getComputedStyle(el).position === 'static') {
523+
el.style.position = 'relative';
524+
}
525+
const copyBtn = document.createElement('button');
526+
copyBtn.className = 'copy-btn';
527+
copyBtn.textContent = 'Copy';
528+
copyBtn.addEventListener('click', function(e) {
529+
e.stopPropagation();
530+
const textToCopy = el.textContent.replace(/^Copy$/, '').trim();
531+
navigator.clipboard.writeText(textToCopy).then(function() {
532+
copyBtn.textContent = 'Copied!';
533+
copyBtn.classList.add('copied');
534+
setTimeout(function() {
535+
copyBtn.textContent = 'Copy';
536+
copyBtn.classList.remove('copied');
537+
}, 2000);
538+
}).catch(function(err) {
539+
console.error('Failed to copy:', err);
540+
});
541+
});
542+
el.appendChild(copyBtn);
543+
});
512544
</script>
513545
</body>
514546
</html>

tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_001_html.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@
8989
.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); }
9090
.expand-btn:hover { background: rgba(0,0,0,0.1); }
9191
.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; }
92+
.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; }
93+
.copy-btn:hover { background: white; color: var(--text-color); }
94+
.copy-btn.copied { background: #c8e6c9; color: #2e7d32; }
95+
pre:hover .copy-btn, .tool-result:hover .copy-btn, .truncatable:hover .copy-btn { opacity: 1; }
96+
.code-container { position: relative; }
9297
.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; }
9398
.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; }
9499
.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); }
@@ -320,6 +325,33 @@ <h1><a href="index.html" style="color: inherit; text-decoration: none;">Claude C
320325
});
321326
}
322327
});
328+
// Add copy buttons to pre elements and tool results
329+
document.querySelectorAll('pre, .tool-result .truncatable-content, .bash-command').forEach(function(el) {
330+
// Skip if already has a copy button
331+
if (el.querySelector('.copy-btn')) return;
332+
// Make container relative if needed
333+
if (getComputedStyle(el).position === 'static') {
334+
el.style.position = 'relative';
335+
}
336+
const copyBtn = document.createElement('button');
337+
copyBtn.className = 'copy-btn';
338+
copyBtn.textContent = 'Copy';
339+
copyBtn.addEventListener('click', function(e) {
340+
e.stopPropagation();
341+
const textToCopy = el.textContent.replace(/^Copy$/, '').trim();
342+
navigator.clipboard.writeText(textToCopy).then(function() {
343+
copyBtn.textContent = 'Copied!';
344+
copyBtn.classList.add('copied');
345+
setTimeout(function() {
346+
copyBtn.textContent = 'Copy';
347+
copyBtn.classList.remove('copied');
348+
}, 2000);
349+
}).catch(function(err) {
350+
console.error('Failed to copy:', err);
351+
});
352+
});
353+
el.appendChild(copyBtn);
354+
});
323355
</script>
324356
</body>
325357
</html>

tests/__snapshots__/test_generate_html/TestGenerateHtml.test_generates_page_002_html.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@
8989
.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); }
9090
.expand-btn:hover { background: rgba(0,0,0,0.1); }
9191
.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; }
92+
.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; }
93+
.copy-btn:hover { background: white; color: var(--text-color); }
94+
.copy-btn.copied { background: #c8e6c9; color: #2e7d32; }
95+
pre:hover .copy-btn, .tool-result:hover .copy-btn, .truncatable:hover .copy-btn { opacity: 1; }
96+
.code-container { position: relative; }
9297
.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; }
9398
.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; }
9499
.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); }
@@ -217,6 +222,33 @@ <h1><a href="index.html" style="color: inherit; text-decoration: none;">Claude C
217222
});
218223
}
219224
});
225+
// Add copy buttons to pre elements and tool results
226+
document.querySelectorAll('pre, .tool-result .truncatable-content, .bash-command').forEach(function(el) {
227+
// Skip if already has a copy button
228+
if (el.querySelector('.copy-btn')) return;
229+
// Make container relative if needed
230+
if (getComputedStyle(el).position === 'static') {
231+
el.style.position = 'relative';
232+
}
233+
const copyBtn = document.createElement('button');
234+
copyBtn.className = 'copy-btn';
235+
copyBtn.textContent = 'Copy';
236+
copyBtn.addEventListener('click', function(e) {
237+
e.stopPropagation();
238+
const textToCopy = el.textContent.replace(/^Copy$/, '').trim();
239+
navigator.clipboard.writeText(textToCopy).then(function() {
240+
copyBtn.textContent = 'Copied!';
241+
copyBtn.classList.add('copied');
242+
setTimeout(function() {
243+
copyBtn.textContent = 'Copy';
244+
copyBtn.classList.remove('copied');
245+
}, 2000);
246+
}).catch(function(err) {
247+
console.error('Failed to copy:', err);
248+
});
249+
});
250+
el.appendChild(copyBtn);
251+
});
220252
</script>
221253
</body>
222254
</html>

tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@
8989
.expand-btn { display: none; width: 100%; padding: 8px 16px; margin-top: 4px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1); border-radius: 6px; cursor: pointer; font-size: 0.85rem; color: var(--text-muted); }
9090
.expand-btn:hover { background: rgba(0,0,0,0.1); }
9191
.truncatable.truncated .expand-btn, .truncatable.expanded .expand-btn { display: block; }
92+
.copy-btn { position: absolute; top: 8px; right: 8px; padding: 4px 8px; background: rgba(255,255,255,0.9); border: 1px solid rgba(0,0,0,0.2); border-radius: 4px; cursor: pointer; font-size: 0.75rem; color: var(--text-muted); opacity: 0; transition: opacity 0.2s; z-index: 10; }
93+
.copy-btn:hover { background: white; color: var(--text-color); }
94+
.copy-btn.copied { background: #c8e6c9; color: #2e7d32; }
95+
pre:hover .copy-btn, .tool-result:hover .copy-btn, .truncatable:hover .copy-btn { opacity: 1; }
96+
.code-container { position: relative; }
9297
.pagination { display: flex; justify-content: center; gap: 8px; margin: 24px 0; flex-wrap: wrap; }
9398
.pagination a, .pagination span { padding: 5px 10px; border-radius: 6px; text-decoration: none; font-size: 0.85rem; }
9499
.pagination a { background: var(--card-bg); color: var(--user-border); border: 1px solid var(--user-border); }
@@ -500,6 +505,33 @@ <h1>Claude Code transcript</h1>
500505
});
501506
}
502507
});
508+
// Add copy buttons to pre elements and tool results
509+
document.querySelectorAll('pre, .tool-result .truncatable-content, .bash-command').forEach(function(el) {
510+
// Skip if already has a copy button
511+
if (el.querySelector('.copy-btn')) return;
512+
// Make container relative if needed
513+
if (getComputedStyle(el).position === 'static') {
514+
el.style.position = 'relative';
515+
}
516+
const copyBtn = document.createElement('button');
517+
copyBtn.className = 'copy-btn';
518+
copyBtn.textContent = 'Copy';
519+
copyBtn.addEventListener('click', function(e) {
520+
e.stopPropagation();
521+
const textToCopy = el.textContent.replace(/^Copy$/, '').trim();
522+
navigator.clipboard.writeText(textToCopy).then(function() {
523+
copyBtn.textContent = 'Copied!';
524+
copyBtn.classList.add('copied');
525+
setTimeout(function() {
526+
copyBtn.textContent = 'Copy';
527+
copyBtn.classList.remove('copied');
528+
}, 2000);
529+
}).catch(function(err) {
530+
console.error('Failed to copy:', err);
531+
});
532+
});
533+
el.appendChild(copyBtn);
534+
});
503535
</script>
504536
</body>
505537
</html>

tests/test_generate_html.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,3 +1570,41 @@ def test_search_total_pages_available(self, output_dir):
15701570

15711571
# Total pages should be embedded for JS to know how many pages to fetch
15721572
assert "totalPages" in index_html or "total_pages" in index_html
1573+
1574+
1575+
class TestCopyButtonFeature:
1576+
"""Tests for copy button functionality."""
1577+
1578+
def test_copy_button_css_present(self, output_dir):
1579+
"""Test that copy button CSS styles are present."""
1580+
fixture_path = Path(__file__).parent / "sample_session.json"
1581+
generate_html(fixture_path, output_dir, github_repo="example/project")
1582+
1583+
page_html = (output_dir / "page-001.html").read_text(encoding="utf-8")
1584+
1585+
# CSS should style the copy button
1586+
assert ".copy-btn" in page_html
1587+
1588+
def test_copy_button_javascript_present(self, output_dir):
1589+
"""Test that copy button JavaScript functionality is present."""
1590+
fixture_path = Path(__file__).parent / "sample_session.json"
1591+
generate_html(fixture_path, output_dir, github_repo="example/project")
1592+
1593+
page_html = (output_dir / "page-001.html").read_text(encoding="utf-8")
1594+
1595+
# JavaScript should handle clipboard API
1596+
assert "clipboard" in page_html.lower() or "navigator.clipboard" in page_html
1597+
1598+
def test_expand_button_has_clear_state(self, output_dir):
1599+
"""Test that expand button has clear expanded/collapsed indicators."""
1600+
fixture_path = Path(__file__).parent / "sample_session.json"
1601+
generate_html(fixture_path, output_dir, github_repo="example/project")
1602+
1603+
page_html = (output_dir / "page-001.html").read_text(encoding="utf-8")
1604+
1605+
# Should have indicators for expand/collapse state (chevrons or similar)
1606+
assert (
1607+
"▼" in page_html
1608+
or "chevron" in page_html.lower()
1609+
or "expand" in page_html.lower()
1610+
)

0 commit comments

Comments
 (0)