From 6f4e42c8159315ad074cbfe839ab42521e80ea49 Mon Sep 17 00:00:00 2001 From: maxno Date: Sat, 1 Aug 2026 13:55:52 +0200 Subject: [PATCH 1/2] Add a Resume Session button to the transcript HTML toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-session HTML pages get a floating '▶ Resume Session' button that copies `cd && claude -r ` to the clipboard and shows a toast prompting the user to paste it into a terminal. The command's quoting follows the OS the transcript was recorded on, detected from the recorded cwd's path shape (the host-decoupled idiom of path_looks_absolute, #151) — the session must be resumed on the machine that recorded it, wherever the page is viewed. Combined pages spanning several trunk sessions don't render the button since `claude -r` would be ambiguous there. The TUI already resumes sessions (the 'c' binding execs claude -r); this brings the same capability to the HTML output, where spawning a process isn't possible. Co-Authored-By: Claude Fable 5 in the session with id 4229268a --- claude_code_log/html/renderer.py | 24 +- .../templates/components/global_styles.css | 36 + .../html/templates/transcript.html | 46 ++ claude_code_log/utils.py | 26 + test/__snapshots__/test_snapshot_html.ambr | 768 ++++++++++++++++++ test/test_resume_session_browser.py | 133 +++ test/test_resume_session_button.py | 96 +++ 7 files changed, 1128 insertions(+), 1 deletion(-) create mode 100644 test/test_resume_session_browser.py create mode 100644 test/test_resume_session_button.py diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index a2463f43..93ccb86e 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -112,7 +112,13 @@ report_timing_statistics, set_timing_var, ) -from ..utils import format_timestamp, split_websearch_queries +from ..utils import ( + collect_trunk_session_ids, + format_timestamp, + get_warmup_session_ids, + resume_command_for_session, + split_websearch_queries, +) from .system_formatters import ( format_away_summary_content, format_hook_attachment_content, @@ -1611,6 +1617,7 @@ def generate( session_tree=session_tree, page_info=page_info, page_stats=page_stats, + repo_cwd=repo_cwd, ) def _generate_inner( @@ -1622,6 +1629,7 @@ def _generate_inner( session_tree: Optional["SessionTree"] = None, page_info: Optional[dict[str, Any]] = None, page_stats: Optional[dict[str, Any]] = None, + repo_cwd: Optional[str] = None, ) -> str: """Body of ``generate`` running inside the SHA-resolver context.""" import time @@ -1664,6 +1672,19 @@ def _generate_inner( with log_timing("Content formatting (pre-order)", t_start): render_roots = self._annotate_tree_for_render(root_messages) + # Resume button: only pages holding a single trunk session get + # one — `claude -r ` is unambiguous there. Combined + # pages spanning several sessions don't (which session would + # resume?). + resume_command = None + trunk_sids = collect_trunk_session_ids( + messages, get_warmup_session_ids(messages) + ) + if len(trunk_sids) == 1: + resume_command = resume_command_for_session( + next(iter(trunk_sids)), repo_cwd + ) + # Render template with log_timing("Template environment setup", t_start): env = get_template_environment() @@ -1684,6 +1705,7 @@ def _generate_inner( is_session_header=is_session_header, page_info=page_info, page_stats=page_stats, + resume_command=resume_command, ) ) diff --git a/claude_code_log/html/templates/components/global_styles.css b/claude_code_log/html/templates/components/global_styles.css index e94fe948..248d3212 100644 --- a/claude_code_log/html/templates/components/global_styles.css +++ b/claude_code_log/html/templates/components/global_styles.css @@ -362,6 +362,42 @@ body.show-raw-user .user-content:not([data-user-view="md"]) .user-raw { color: #333; } +/* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ +.resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; +} + +/* Transient confirmation shown after the resume command is copied. */ +.resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; +} + +.resume-toast.visible { + opacity: 1; +} + @media (max-width: 1280px) { .header > span:first-child { flex: auto; diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index 89a11203..c72a330f 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -244,6 +244,10 @@

🔍 Search & Filter

{% for root in roots %}{{ render_message(root) }}{% endfor %} + {% if resume_command %} + + {% endif %} @@ -272,6 +276,48 @@

🔍 Search & Filter

debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index 34d16653..94ab1c58 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -212,6 +212,32 @@ def get_project_display_name( return best_working_dir(project_dir_name, working_directories)[0] +def resume_command_for_session(session_id: str, cwd: Optional[str]) -> str: + """Build a shell one-liner that resumes ``session_id`` in Claude Code. + + ``cwd`` is the session's recorded working directory; the command + changes there first so ``claude -r`` runs in the right project. + Quoting follows the OS the *transcript* was recorded on (detected + from the path shape, like :func:`path_looks_absolute`), not the + host rendering the HTML — a Windows-recorded session must be + resumed in a Windows shell regardless of where the page is viewed. + + Returns a bare ``claude -r`` command when no cwd was recorded. + """ + if not cwd: + return f"claude -r {session_id}" + from pathlib import PureWindowsPath + + if PureWindowsPath(cwd).drive: + # Windows shells (PowerShell 7+, cmd): double quotes handle + # spaces; backslashes are literal inside them. + return f'cd "{cwd}" && claude -r {session_id}' + # POSIX shells: shlex protects spaces and metacharacters. + import shlex + + return f"cd {shlex.quote(cwd)} && claude -r {session_id}" + + def path_looks_absolute(s: str) -> bool: """True if ``s`` looks like an absolute path on either POSIX or Windows. Decoupled from the host OS so JSONL-stored cwds don't diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 9d0a15a6..807002c4 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -374,6 +374,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -5020,6 +5056,10 @@ + + + @@ -5162,6 +5202,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: @@ -7196,6 +7278,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -11737,6 +11855,10 @@ + + + @@ -11879,6 +12001,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: @@ -13913,6 +14077,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -16342,6 +16542,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -21106,6 +21342,10 @@ + + + @@ -21248,6 +21488,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: @@ -23282,6 +23564,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -28326,6 +28644,10 @@ + + + @@ -28468,6 +28790,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: @@ -30502,6 +30866,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -35497,6 +35897,7 @@ + @@ -35639,6 +36040,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: @@ -37673,6 +38116,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -42613,6 +43092,7 @@ + @@ -42755,6 +43235,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: @@ -44789,6 +45311,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -49553,6 +50111,10 @@ + + + @@ -49695,6 +50257,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: @@ -51729,6 +52333,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -56298,6 +56938,10 @@ + + + @@ -56440,6 +57084,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: @@ -58474,6 +59160,42 @@ color: #333; } + /* Resume-session button (single-session pages only): copies the + * `cd … && claude -r ` command to the clipboard. */ + .resume-session.floating-btn { + bottom: 340px; + border-radius: 6px; + width: auto; + height: 28px; + padding: 0 10px; + font-size: 0.65em; + font-family: 'SFMono-Regular', Consolas, monospace; + font-weight: 600; + white-space: nowrap; + } + + /* Transient confirmation shown after the resume command is copied. */ + .resume-toast { + position: fixed; + right: 20px; + bottom: 380px; + max-width: 320px; + padding: 8px 12px; + background-color: var(--session-bg-dimmed); + color: var(--text-muted); + border-radius: 6px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2); + font-size: 0.8em; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1000; + } + + .resume-toast.visible { + opacity: 1; + } + @media (max-width: 1280px) { .header > span:first-child { flex: auto; @@ -62992,6 +63714,10 @@ + + + @@ -63134,6 +63860,48 @@ debugButton.classList.toggle('active'); }); + // Resume session: copy the resume command to the clipboard + // and prompt the user to paste it into a terminal. The + // command is built server-side (data-command) from the + // session's recorded cwd, so its quoting matches the OS + // the transcript was recorded on — which may differ from + // the OS viewing this page. + const resumeButton = document.getElementById('resumeSession'); + if (resumeButton) { + let resumeToastTimer = null; + function showResumeToast(message) { + let toast = document.getElementById('resumeToast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'resumeToast'; + toast.className = 'resume-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + if (resumeToastTimer) clearTimeout(resumeToastTimer); + resumeToastTimer = setTimeout(function () { + toast.classList.remove('visible'); + }, 5000); + } + resumeButton.addEventListener('click', function () { + const command = resumeButton.dataset.command; + function copied() { + showResumeToast('📋 Copied! Paste into your terminal to resume this session.'); + } + function fallback() { + // Clipboard API unavailable or refused: let the + // user copy from a prompt instead. + window.prompt('Copy this command, then paste it into your terminal:', command); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(command).then(copied, fallback); + } else { + fallback(); + } + }); + } + // User-content view toggle (Markdown / raw). // // Two toggles: diff --git a/test/test_resume_session_browser.py b/test/test_resume_session_browser.py new file mode 100644 index 00000000..77e61a03 --- /dev/null +++ b/test/test_resume_session_browser.py @@ -0,0 +1,133 @@ +"""Playwright tests for the Resume Session floating button. + +Covers the live-JS behavior the server-side unit tests can't reach: +clicking the button writes the resume command to the clipboard and +shows the paste-into-terminal toast. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import List, Optional + +import pytest +from playwright.sync_api import Page, expect + +from claude_code_log.converter import load_transcript +from claude_code_log.html.renderer import generate_html +from claude_code_log.models import TranscriptEntry + + +def _user_entry( + uuid: str, + text: str, + session_id: str = "test_session", + cwd: str = "/tmp/project", + ts: str = "2026-01-01T10:00:00.000Z", +) -> dict: + return { + "type": "user", + "timestamp": ts, + "parentUuid": None, + "isSidechain": False, + "userType": "external", + "cwd": cwd, + "sessionId": session_id, + "version": "1.0.0", + "uuid": uuid, + "message": { + "role": "user", + "content": [{"type": "text", "text": text}], + }, + } + + +class TestResumeSessionBrowser: + """Live-browser tests for the Resume Session button.""" + + def setup_method(self) -> None: + self.temp_files: List[Path] = [] + + def teardown_method(self) -> None: + for f in self.temp_files: + try: + f.unlink() + except FileNotFoundError: + pass + + def _render(self, entries: List[dict], title: str = "Resume Test") -> Path: + """Write entries to a JSONL, render to HTML, return the HTML path.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".jsonl", delete=False, encoding="utf-8" + ) as f: + for e in entries: + f.write(json.dumps(e) + "\n") + jsonl_path = Path(f.name) + self.temp_files.append(jsonl_path) + + messages: List[TranscriptEntry] = load_transcript(jsonl_path) + html_content = generate_html(messages, title) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".html", delete=False, encoding="utf-8" + ) as f: + f.write(html_content) + html_path = Path(f.name) + self.temp_files.append(html_path) + return html_path + + def _goto_with_clipboard_stub(self, page: Page, html: Path) -> None: + """Navigate to the rendered HTML with ``navigator.clipboard`` + stubbed to record writes into ``window.__copied``. file:// pages + can't reliably get real clipboard permission in the test + browser, and a stub also lets the test assert the exact text.""" + page.add_init_script( + """ + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: function (text) { + window.__copied = text; + return Promise.resolve(); + } + }, + configurable: true + }); + """ + ) + page.goto(f"file://{html}") + + @pytest.mark.browser + def test_click_copies_command_and_shows_toast(self, page: Page) -> None: + html = self._render( + [_user_entry("u1", "hello", session_id="ab12cd34", cwd="/tmp/project")] + ) + self._goto_with_clipboard_stub(page, html) + + button = page.locator("#resumeSession") + expect(button).to_be_visible() + expect(button).to_have_text("▶ Resume Session") + + button.click() + copied: Optional[str] = page.evaluate("() => window.__copied") + assert copied == "cd /tmp/project && claude -r ab12cd34" + + toast = page.locator("#resumeToast") + expect(toast).to_be_visible() + expect(toast).to_contain_text("Paste into your terminal") + + @pytest.mark.browser + def test_no_button_on_multi_session_page(self, page: Page) -> None: + html = self._render( + [ + _user_entry("u1", "first", session_id="session_a"), + _user_entry( + "u2", + "second", + session_id="session_b", + ts="2026-01-01T10:01:00.000Z", + ), + ] + ) + page.goto(f"file://{html}") + expect(page.locator("#resumeSession")).to_have_count(0) diff --git a/test/test_resume_session_button.py b/test/test_resume_session_button.py new file mode 100644 index 00000000..0da880f5 --- /dev/null +++ b/test/test_resume_session_button.py @@ -0,0 +1,96 @@ +"""Tests for the Resume Session floating button and its command builder.""" + +from typing import Optional + +from claude_code_log.html.renderer import generate_html +from claude_code_log.models import ( + TextContent, + UserMessageModel, + UserTranscriptEntry, +) +from claude_code_log.utils import resume_command_for_session + +SESSION_A = "c2688f20-2ca1-410d-a82b-1a7f11761315" +SESSION_B = "37f83ec9-f2ea-42a9-925e-0d5c105cb6e8" + + +def _user_entry(session_id: str, cwd: Optional[str], uuid: str) -> UserTranscriptEntry: + return UserTranscriptEntry( + type="user", + timestamp="2025-06-14T10:00:00.000Z", + parentUuid=None, + isSidechain=False, + userType="human", + cwd=cwd or "", + sessionId=session_id, + version="1.0.0", + uuid=uuid, + message=UserMessageModel( + role="user", + content=[TextContent(type="text", text=f"Hello from {uuid}")], + ), + ) + + +class TestResumeCommandForSession: + """Command shape follows the OS the transcript was recorded on.""" + + def test_windows_cwd_uses_double_quotes(self): + assert ( + resume_command_for_session(SESSION_A, "C:\\Users\\maxno") + == f'cd "C:\\Users\\maxno" && claude -r {SESSION_A}' + ) + + def test_windows_cwd_with_spaces(self): + assert ( + resume_command_for_session(SESSION_A, "C:\\My Projects\\app") + == f'cd "C:\\My Projects\\app" && claude -r {SESSION_A}' + ) + + def test_posix_cwd(self): + assert ( + resume_command_for_session(SESSION_A, "/Users/dain/workspace") + == f"cd /Users/dain/workspace && claude -r {SESSION_A}" + ) + + def test_posix_cwd_with_spaces_is_quoted(self): + assert ( + resume_command_for_session(SESSION_A, "/home/u/my project") + == f"cd '/home/u/my project' && claude -r {SESSION_A}" + ) + + def test_no_cwd_falls_back_to_bare_resume(self): + assert resume_command_for_session(SESSION_A, None) == f"claude -r {SESSION_A}" + + +class TestResumeButtonInHtml: + """The button renders only on single-session pages.""" + + def test_single_session_page_has_button_with_command(self): + html = generate_html( + [_user_entry(SESSION_A, "/Users/dain/workspace", "uuid-1")], + "Test Resume", + ) + assert 'id="resumeSession"' in html + assert "▶ Resume Session" in html + assert ( + f'data-command="cd /Users/dain/workspace && claude -r {SESSION_A}"' + in html + ) + + def test_windows_session_command_is_escaped_into_attribute(self): + html = generate_html( + [_user_entry(SESSION_A, "C:\\Users\\maxno", "uuid-1")], + "Test Resume Windows", + ) + assert f"cd "C:\\Users\\maxno" && claude -r {SESSION_A}" in html + + def test_multi_session_page_has_no_button(self): + html = generate_html( + [ + _user_entry(SESSION_A, "/Users/dain/workspace", "uuid-1"), + _user_entry(SESSION_B, "/Users/dain/workspace", "uuid-2"), + ], + "Test Combined", + ) + assert 'id="resumeSession"' not in html From 24e1cb15f9f87a40c68372d981f75c314053e656 Mon Sep 17 00:00:00 2001 From: maxno Date: Sat, 1 Aug 2026 14:17:50 +0200 Subject: [PATCH 2/2] Address review: harden resume command, toast a11y, test fixes - resume_command_for_session returns None (no button) for session ids outside a conservative charset, Windows cwds containing " % ! $ or backtick (cmd/PowerShell expand those inside double quotes), and any cwd with a newline (pasting multi-line text can execute each line). Regression tests for each rejection plus shlex-escaping of a single quote in a POSIX cwd. - Toast gets role=status + aria-live=polite so screen readers announce the copy confirmation; snapshots regenerated serially (additive). - Browser test navigations use Path.as_uri(); docstrings added to new test helpers and methods. Co-Authored-By: Claude Fable 5 in the session with id 4229268a --- .../html/templates/transcript.html | 4 ++ claude_code_log/utils.py | 25 ++++++++++- test/__snapshots__/test_snapshot_html.ambr | 36 +++++++++++++++ test/test_resume_session_browser.py | 9 +++- test/test_resume_session_button.py | 44 +++++++++++++++++++ 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/claude_code_log/html/templates/transcript.html b/claude_code_log/html/templates/transcript.html index c72a330f..3f2470c9 100644 --- a/claude_code_log/html/templates/transcript.html +++ b/claude_code_log/html/templates/transcript.html @@ -291,6 +291,10 @@

🔍 Search & Filter

toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index 94ab1c58..12070e83 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -212,7 +212,18 @@ def get_project_display_name( return best_working_dir(project_dir_name, working_directories)[0] -def resume_command_for_session(session_id: str, cwd: Optional[str]) -> str: +# The resume command is pasted into a shell, and transcript fields are +# untrusted input (same threat model as the HTML escaping in #245) — so +# both values are held to conservative charsets and the button is +# skipped entirely rather than risk smuggling shell syntax. +_RESUME_SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") +# Inside double quotes, cmd still expands %var% / delayed-expansion +# !var!, and PowerShell expands $var and `x escapes; a literal " would +# end the quoting altogether. +_WINDOWS_CWD_UNSAFE_RE = re.compile(r'["%!$`]') + + +def resume_command_for_session(session_id: str, cwd: Optional[str]) -> Optional[str]: """Build a shell one-liner that resumes ``session_id`` in Claude Code. ``cwd`` is the session's recorded working directory; the command @@ -222,13 +233,23 @@ def resume_command_for_session(session_id: str, cwd: Optional[str]) -> str: host rendering the HTML — a Windows-recorded session must be resumed in a Windows shell regardless of where the page is viewed. - Returns a bare ``claude -r`` command when no cwd was recorded. + Returns a bare ``claude -r`` command when no cwd was recorded, and + ``None`` (no button) when the session id or a Windows cwd contains + characters a shell could interpret. Newlines are rejected in every + position: pasting a multi-line clipboard can execute each line + immediately, so quoting alone is no defence. """ + if not _RESUME_SESSION_ID_RE.fullmatch(session_id): + return None if not cwd: return f"claude -r {session_id}" + if "\n" in cwd or "\r" in cwd: + return None from pathlib import PureWindowsPath if PureWindowsPath(cwd).drive: + if _WINDOWS_CWD_UNSAFE_RE.search(cwd): + return None # Windows shells (PowerShell 7+, cmd): double quotes handle # spaces; backslashes are literal inside them. return f'cd "{cwd}" && claude -r {session_id}' diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 807002c4..833730d2 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -5217,6 +5217,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; @@ -12016,6 +12020,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; @@ -21503,6 +21511,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; @@ -28805,6 +28817,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; @@ -36055,6 +36071,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; @@ -43250,6 +43270,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; @@ -50272,6 +50296,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; @@ -57099,6 +57127,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; @@ -63875,6 +63907,10 @@ toast = document.createElement('div'); toast.id = 'resumeToast'; toast.className = 'resume-toast'; + // Live-region semantics so screen readers + // announce the copy confirmation. + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); document.body.appendChild(toast); } toast.textContent = message; diff --git a/test/test_resume_session_browser.py b/test/test_resume_session_browser.py index 77e61a03..9bf014fe 100644 --- a/test/test_resume_session_browser.py +++ b/test/test_resume_session_browser.py @@ -27,6 +27,7 @@ def _user_entry( cwd: str = "/tmp/project", ts: str = "2026-01-01T10:00:00.000Z", ) -> dict: + """Build a raw user-entry dict for a JSONL fixture.""" return { "type": "user", "timestamp": ts, @@ -48,9 +49,11 @@ class TestResumeSessionBrowser: """Live-browser tests for the Resume Session button.""" def setup_method(self) -> None: + """Track temp files created by the test for cleanup.""" self.temp_files: List[Path] = [] def teardown_method(self) -> None: + """Remove the temp files created during the test.""" for f in self.temp_files: try: f.unlink() @@ -95,10 +98,11 @@ def _goto_with_clipboard_stub(self, page: Page, html: Path) -> None: }); """ ) - page.goto(f"file://{html}") + page.goto(html.as_uri()) @pytest.mark.browser def test_click_copies_command_and_shows_toast(self, page: Page) -> None: + """Clicking the button copies the exact command and shows the toast.""" html = self._render( [_user_entry("u1", "hello", session_id="ab12cd34", cwd="/tmp/project")] ) @@ -118,6 +122,7 @@ def test_click_copies_command_and_shows_toast(self, page: Page) -> None: @pytest.mark.browser def test_no_button_on_multi_session_page(self, page: Page) -> None: + """Combined pages spanning several sessions render no button.""" html = self._render( [ _user_entry("u1", "first", session_id="session_a"), @@ -129,5 +134,5 @@ def test_no_button_on_multi_session_page(self, page: Page) -> None: ), ] ) - page.goto(f"file://{html}") + page.goto(html.as_uri()) expect(page.locator("#resumeSession")).to_have_count(0) diff --git a/test/test_resume_session_button.py b/test/test_resume_session_button.py index 0da880f5..17e39996 100644 --- a/test/test_resume_session_button.py +++ b/test/test_resume_session_button.py @@ -15,6 +15,7 @@ def _user_entry(session_id: str, cwd: Optional[str], uuid: str) -> UserTranscriptEntry: + """Build a minimal user entry for rendering tests.""" return UserTranscriptEntry( type="user", timestamp="2025-06-14T10:00:00.000Z", @@ -36,37 +37,70 @@ class TestResumeCommandForSession: """Command shape follows the OS the transcript was recorded on.""" def test_windows_cwd_uses_double_quotes(self): + """A drive-lettered cwd gets Windows double-quote quoting.""" assert ( resume_command_for_session(SESSION_A, "C:\\Users\\maxno") == f'cd "C:\\Users\\maxno" && claude -r {SESSION_A}' ) def test_windows_cwd_with_spaces(self): + """Spaces in a Windows cwd stay inside the double quotes.""" assert ( resume_command_for_session(SESSION_A, "C:\\My Projects\\app") == f'cd "C:\\My Projects\\app" && claude -r {SESSION_A}' ) def test_posix_cwd(self): + """A plain POSIX cwd needs no quoting at all.""" assert ( resume_command_for_session(SESSION_A, "/Users/dain/workspace") == f"cd /Users/dain/workspace && claude -r {SESSION_A}" ) def test_posix_cwd_with_spaces_is_quoted(self): + """Spaces in a POSIX cwd get shlex single-quoting.""" assert ( resume_command_for_session(SESSION_A, "/home/u/my project") == f"cd '/home/u/my project' && claude -r {SESSION_A}" ) def test_no_cwd_falls_back_to_bare_resume(self): + """Without a recorded cwd the command is a bare claude -r.""" assert resume_command_for_session(SESSION_A, None) == f"claude -r {SESSION_A}" + def test_posix_cwd_with_single_quote_is_escaped(self): + """shlex neutralizes an embedded single quote in a POSIX cwd.""" + command = resume_command_for_session(SESSION_A, "/home/u/it's") + assert command == f"""cd '/home/u/it'"'"'s' && claude -r {SESSION_A}""" + + def test_session_id_with_shell_metacharacters_is_rejected(self): + """A session id carrying shell syntax yields no command at all.""" + assert resume_command_for_session("evil; rm -rf ~", "/tmp") is None + assert resume_command_for_session("$(whoami)", "/tmp") is None + assert resume_command_for_session("", "/tmp") is None + + def test_windows_cwd_with_embedded_quote_is_rejected(self): + """A double quote in a Windows cwd would break out of the quoting.""" + assert resume_command_for_session(SESSION_A, 'C:\\evil" & calc & "') is None + + def test_windows_cwd_with_expansion_characters_is_rejected(self): + """cmd %var%/!var! and PowerShell $var/`x expand inside double quotes.""" + assert resume_command_for_session(SESSION_A, "C:\\x%TEMP%") is None + assert resume_command_for_session(SESSION_A, "C:\\x$(calc)") is None + assert resume_command_for_session(SESSION_A, "C:\\x`n") is None + assert resume_command_for_session(SESSION_A, "C:\\x!var!") is None + + def test_cwd_with_newline_is_rejected(self): + """Pasting multi-line text can execute each line — reject outright.""" + assert resume_command_for_session(SESSION_A, "/tmp/a\nrm -rf ~") is None + assert resume_command_for_session(SESSION_A, "C:\\a\r\ncalc") is None + class TestResumeButtonInHtml: """The button renders only on single-session pages.""" def test_single_session_page_has_button_with_command(self): + """A single-session page renders the button with its command.""" html = generate_html( [_user_entry(SESSION_A, "/Users/dain/workspace", "uuid-1")], "Test Resume", @@ -79,6 +113,7 @@ def test_single_session_page_has_button_with_command(self): ) def test_windows_session_command_is_escaped_into_attribute(self): + """The Windows command lands HTML-escaped in the data attribute.""" html = generate_html( [_user_entry(SESSION_A, "C:\\Users\\maxno", "uuid-1")], "Test Resume Windows", @@ -86,6 +121,7 @@ def test_windows_session_command_is_escaped_into_attribute(self): assert f"cd "C:\\Users\\maxno" && claude -r {SESSION_A}" in html def test_multi_session_page_has_no_button(self): + """Combined pages spanning several sessions render no button.""" html = generate_html( [ _user_entry(SESSION_A, "/Users/dain/workspace", "uuid-1"), @@ -94,3 +130,11 @@ def test_multi_session_page_has_no_button(self): "Test Combined", ) assert 'id="resumeSession"' not in html + + def test_unsafe_session_id_renders_no_button(self): + """A transcript with a shell-unsafe session id gets no button.""" + html = generate_html( + [_user_entry("evil; rm -rf ~", "/tmp", "uuid-1")], + "Test Unsafe", + ) + assert 'id="resumeSession"' not in html